v1.9.0 #437
@@ -99,6 +99,9 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
|
||||
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
@@ -132,6 +135,9 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -245,6 +251,7 @@ export const commandHistory = sqliteTable("command_history", {
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
command: text("command").notNull(),
|
||||
executedAt: text("executed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
sshFolders,
|
||||
commandHistory,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -355,6 +356,29 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Notify stats server to start polling this host
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-updated`,
|
||||
{ hostId: createdHost.id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of new host", {
|
||||
operation: "host_create",
|
||||
hostId: createdHost.id as number,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
@@ -570,6 +594,29 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Notify stats server to refresh polling for this host
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-updated`,
|
||||
{ hostId: parseInt(hostId) },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host update", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update SSH host in database", err, {
|
||||
@@ -1224,6 +1271,94 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Get command history for a host
|
||||
// GET /ssh/command-history/:hostId
|
||||
router.get(
|
||||
"/command-history/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
sshLogger.warn("Invalid userId or hostId for command history fetch", {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await db
|
||||
.select({
|
||||
id: commandHistory.id,
|
||||
command: commandHistory.command,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(commandHistory.executedAt))
|
||||
.limit(200);
|
||||
|
||||
res.json(history.map((h) => h.command));
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch command history from database", err, {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch command history" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Delete command from history
|
||||
// DELETE /ssh/command-history
|
||||
router.delete(
|
||||
"/command-history",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !command) {
|
||||
sshLogger.warn("Invalid data for command history deletion", {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
eq(commandHistory.command, command),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ message: "Command deleted from history" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete command from history", err, {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
command,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete command" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function resolveHostCredentials(
|
||||
host: Record<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;
|
||||
|
||||
@@ -820,11 +820,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
port,
|
||||
});
|
||||
jumpClient.end();
|
||||
return res
|
||||
.status(500)
|
||||
.json({
|
||||
error: "Failed to forward through jump host: " + err.message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to forward through jump host: " + err.message,
|
||||
});
|
||||
}
|
||||
|
||||
config.sock = stream;
|
||||
@@ -2732,9 +2730,12 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const octalPerms = permissions.slice(-3);
|
||||
const escapedPath = path.replace(/'/g, "'\"'\"'");
|
||||
const command = `chmod ${octalPerms} '${escapedPath}'`;
|
||||
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
fileLogger.info("Changing file permissions", {
|
||||
operation: "change_permissions",
|
||||
@@ -2743,24 +2744,66 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
fileLogger.error("changePermissions command timeout", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
res.status(408).json({
|
||||
error: "Permission change timed out. SSH connection may be unstable.",
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
sshConn.client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions exec error:", err, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to change permissions" });
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: "Failed to change permissions" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error("chmod command failed", {
|
||||
operation: "change_permissions",
|
||||
@@ -2770,9 +2813,12 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
exitCode: code,
|
||||
error: errorOutput,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: errorOutput || "Failed to change permissions",
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: errorOutput || "Failed to change permissions",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
@@ -2782,13 +2828,16 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions stream error:", streamErr, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
|
||||
@@ -665,6 +665,7 @@ class PollingManager {
|
||||
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
||||
const existingConfig = this.pollingConfigs.get(host.id);
|
||||
|
||||
// Always clear existing timers first
|
||||
if (existingConfig) {
|
||||
if (existingConfig.statusTimer) {
|
||||
clearInterval(existingConfig.statusTimer);
|
||||
@@ -674,10 +675,19 @@ class PollingManager {
|
||||
}
|
||||
}
|
||||
|
||||
// If both checks are disabled, stop all polling and clean up
|
||||
if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
|
||||
this.pollingConfigs.delete(host.id);
|
||||
this.statusStore.delete(host.id);
|
||||
this.metricsStore.delete(host.id);
|
||||
statsLogger.info(
|
||||
`Stopped all polling for host ${host.id} (${host.name || host.ip}) - both checks disabled`,
|
||||
{
|
||||
operation: "polling_stopped",
|
||||
hostId: host.id,
|
||||
hostName: host.name || host.ip,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -694,8 +704,21 @@ class PollingManager {
|
||||
config.statusTimer = setInterval(() => {
|
||||
this.pollHostStatus(host);
|
||||
}, intervalMs);
|
||||
|
||||
statsLogger.debug(
|
||||
`Started status polling for host ${host.id} (interval: ${statsConfig.statusCheckInterval}s)`,
|
||||
{
|
||||
operation: "status_polling_started",
|
||||
hostId: host.id,
|
||||
interval: statsConfig.statusCheckInterval,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.statusStore.delete(host.id);
|
||||
statsLogger.debug(`Status polling disabled for host ${host.id}`, {
|
||||
operation: "status_polling_disabled",
|
||||
hostId: host.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (statsConfig.metricsEnabled) {
|
||||
@@ -706,8 +729,21 @@ class PollingManager {
|
||||
config.metricsTimer = setInterval(() => {
|
||||
this.pollHostMetrics(host);
|
||||
}, intervalMs);
|
||||
|
||||
statsLogger.debug(
|
||||
`Started metrics polling for host ${host.id} (interval: ${statsConfig.metricsInterval}s)`,
|
||||
{
|
||||
operation: "metrics_polling_started",
|
||||
hostId: host.id,
|
||||
interval: statsConfig.metricsInterval,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.metricsStore.delete(host.id);
|
||||
statsLogger.debug(`Metrics polling disabled for host ${host.id}`, {
|
||||
operation: "metrics_polling_disabled",
|
||||
hostId: host.id,
|
||||
});
|
||||
}
|
||||
|
||||
this.pollingConfigs.set(host.id, config);
|
||||
@@ -731,6 +767,12 @@ class PollingManager {
|
||||
}
|
||||
|
||||
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
|
||||
// Double-check that metrics are still enabled before collecting
|
||||
const config = this.pollingConfigs.get(host.id);
|
||||
if (!config || !config.statsConfig.metricsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await collectMetrics(host);
|
||||
this.metricsStore.set(host.id, {
|
||||
@@ -738,12 +780,19 @@ class PollingManager {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
statsLogger.warn("Failed to collect metrics for host", {
|
||||
operation: "metrics_poll_failed",
|
||||
hostId: host.id,
|
||||
hostName: host.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Only log errors if metrics collection is actually enabled
|
||||
// Don't spam logs with errors for hosts that have metrics disabled
|
||||
if (config.statsConfig.metricsEnabled) {
|
||||
statsLogger.warn("Failed to collect metrics for host", {
|
||||
operation: "metrics_poll_failed",
|
||||
hostId: host.id,
|
||||
hostName: host.name,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1374,6 +1423,39 @@ app.post("/refresh", async (req, res) => {
|
||||
res.json({ message: "Polling refreshed" });
|
||||
});
|
||||
|
||||
app.post("/host-updated", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.body;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (host) {
|
||||
await pollingManager.startPollingForHost(host);
|
||||
res.json({ message: "Host polling started" });
|
||||
} else {
|
||||
res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to start polling for host", error, {
|
||||
operation: "host_updated",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to start polling" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
@@ -22,6 +22,7 @@ interface EncryptedDEK {
|
||||
interface UserSession {
|
||||
dataKey: Buffer;
|
||||
expiresAt: number;
|
||||
lastActivity?: number;
|
||||
}
|
||||
|
||||
class UserCrypto {
|
||||
|
||||
@@ -774,7 +774,18 @@
|
||||
"noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.",
|
||||
"noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern.",
|
||||
"forceKeyboardInteractive": "Tastatur-Interaktiv erzwingen",
|
||||
"forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden."
|
||||
"forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden.",
|
||||
"overrideCredentialUsername": "Benutzernamen der Anmeldedaten überschreiben",
|
||||
"overrideCredentialUsernameDesc": "Verwenden Sie einen anderen Benutzernamen als den, der in den Anmeldedaten gespeichert ist. Dadurch können Sie dieselben Anmeldedaten mit unterschiedlichen Benutzernamen verwenden.",
|
||||
"jumpHosts": "Jump-Hosts",
|
||||
"jumpHostsDescription": "Jump-Hosts (auch bekannt als Bastion-Hosts) ermöglichen es Ihnen, sich über einen oder mehrere Zwischen-Server mit einem Ziel-Server zu verbinden. Dies ist nützlich für den Zugriff auf Server hinter Firewalls oder in privaten Netzwerken.",
|
||||
"jumpHostChain": "Jump-Host-Kette",
|
||||
"addJumpHost": "Jump-Host hinzufügen",
|
||||
"selectServer": "Server auswählen",
|
||||
"searchServers": "Server durchsuchen...",
|
||||
"noServerFound": "Kein Server gefunden",
|
||||
"jumpHostsOrder": "Verbindungen werden in dieser Reihenfolge hergestellt: Jump-Host 1 → Jump-Host 2 → ... → Ziel-Server",
|
||||
"advancedAuthSettings": "Erweiterte Authentifizierungseinstellungen"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -482,6 +482,26 @@
|
||||
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
|
||||
"failedToRevokeSessions": "Failed to revoke sessions",
|
||||
"sessionsRevokedSuccessfully": "Sessions revoked successfully",
|
||||
"convertToPasswordAuth": "Convert to Password Authentication",
|
||||
"convertOIDCToPassword": "Convert {{username}} from OIDC/SSO authentication to password-based authentication. This will allow the user to log in with a username and password instead of through an external provider.",
|
||||
"convertUserDialogTitle": "Convert to Password Authentication",
|
||||
"convertUserDialogDescription": "This action will set a new password, disable OIDC/SSO login, log out all sessions, and preserve all user data.",
|
||||
"convertActionWillSetPassword": "Set a new password for this user",
|
||||
"convertActionWillDisableOIDC": "Disable OIDC/SSO login for this account",
|
||||
"convertActionWillLogout": "Log out all active sessions",
|
||||
"convertActionWillPreserveData": "Preserve all user data (SSH hosts, credentials, etc.)",
|
||||
"convertPasswordLabel": "New Password (min 8 chars)",
|
||||
"convertPasswordPlaceholder": "Enter new password",
|
||||
"convertTotpLabel": "TOTP Code (if user has 2FA enabled)",
|
||||
"convertTotpPlaceholder": "000000",
|
||||
"convertUserButton": "Convert User",
|
||||
"convertingUser": "Converting...",
|
||||
"userConvertedSuccessfully": "User {{username}} has been converted to password authentication. All sessions have been revoked.",
|
||||
"failedToConvertUser": "Failed to convert user account",
|
||||
"convertPasswordRequired": "Password is required",
|
||||
"convertPasswordTooShort": "Password must be at least 8 characters long",
|
||||
"convertTotpRequired": "TOTP code is required for this user",
|
||||
"convertToPasswordTitle": "Convert to password authentication",
|
||||
"databaseSecurity": "Database Security",
|
||||
"encryptionStatus": "Encryption Status",
|
||||
"encryptionEnabled": "Encryption Enabled",
|
||||
@@ -860,7 +880,16 @@
|
||||
"forceKeyboardInteractive": "Force Keyboard-Interactive",
|
||||
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Override Credential Username",
|
||||
"overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames."
|
||||
"overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames.",
|
||||
"jumpHosts": "Jump Hosts",
|
||||
"jumpHostsDescription": "Jump hosts (also known as bastion hosts) allow you to connect to a target server through one or more intermediate servers. This is useful for accessing servers behind firewalls or in private networks.",
|
||||
"jumpHostChain": "Jump Host Chain",
|
||||
"addJumpHost": "Add Jump Host",
|
||||
"selectServer": "Select Server",
|
||||
"searchServers": "Search servers...",
|
||||
"noServerFound": "No server found",
|
||||
"jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server",
|
||||
"advancedAuthSettings": "Advanced Authentication Settings"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -765,7 +765,18 @@
|
||||
"noneAuthDescription": "Cette méthode utilisera l'authentification clavier-interactif lors de la connexion au serveur SSH.",
|
||||
"noneAuthDetails": "L'authentification clavier-interactif permet au serveur de vous demander des informations pendant la connexion. Utile pour le MFA ou si vous ne souhaitez pas stocker d'identifiants localement.",
|
||||
"forceKeyboardInteractive": "Forcer le clavier-interactif",
|
||||
"forceKeyboardInteractiveDesc": "Force l'utilisation de l'authentification clavier-interactif. Souvent nécessaire pour les serveurs avec 2FA (TOTP/2FA)."
|
||||
"forceKeyboardInteractiveDesc": "Force l'utilisation de l'authentification clavier-interactif. Souvent nécessaire pour les serveurs avec 2FA (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Remplacer le nom d'utilisateur des identifiants",
|
||||
"overrideCredentialUsernameDesc": "Utilisez un nom d'utilisateur différent de celui stocké dans les identifiants. Cela vous permet d'utiliser les mêmes identifiants avec différents noms d'utilisateur.",
|
||||
"jumpHosts": "Serveurs de rebond",
|
||||
"jumpHostsDescription": "Les serveurs de rebond (également appelés bastions) vous permettent de vous connecter à un serveur cible via un ou plusieurs serveurs intermédiaires. Utile pour accéder à des serveurs derrière des pare-feu ou dans des réseaux privés.",
|
||||
"jumpHostChain": "Chaîne de serveurs de rebond",
|
||||
"addJumpHost": "Ajouter un serveur de rebond",
|
||||
"selectServer": "Sélectionner un serveur",
|
||||
"searchServers": "Rechercher des serveurs...",
|
||||
"noServerFound": "Aucun serveur trouvé",
|
||||
"jumpHostsOrder": "Les connexions seront établies dans l'ordre : Serveur de rebond 1 → Serveur de rebond 2 → ... → Serveur cible",
|
||||
"advancedAuthSettings": "Paramètres d'authentification avancés"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -720,7 +720,18 @@
|
||||
"noneAuthDescription": "Este método de autenticação usará autenticação interativa por teclado ao conectar ao servidor SSH.",
|
||||
"noneAuthDetails": "A autenticação interativa por teclado permite que o servidor solicite credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multifator ou entrada de senha dinâmica.",
|
||||
"forceKeyboardInteractive": "Forçar Interativo com Teclado",
|
||||
"forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA)."
|
||||
"forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Substituir Nome de Usuário da Credencial",
|
||||
"overrideCredentialUsernameDesc": "Use um nome de usuário diferente daquele armazenado na credencial. Isso permite que você use a mesma credencial com diferentes nomes de usuário.",
|
||||
"jumpHosts": "Hosts de Salto",
|
||||
"jumpHostsDescription": "Hosts de salto (também conhecidos como bastions) permitem que você se conecte a um servidor de destino através de um ou mais servidores intermediários. Isso é útil para acessar servidores atrás de firewalls ou em redes privadas.",
|
||||
"jumpHostChain": "Cadeia de Hosts de Salto",
|
||||
"addJumpHost": "Adicionar Host de Salto",
|
||||
"selectServer": "Selecionar Servidor",
|
||||
"searchServers": "Pesquisar servidores...",
|
||||
"noServerFound": "Nenhum servidor encontrado",
|
||||
"jumpHostsOrder": "As conexões serão feitas na ordem: Host de Salto 1 → Host de Salto 2 → ... → Servidor de Destino",
|
||||
"advancedAuthSettings": "Configurações Avançadas de Autenticação"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -831,7 +831,20 @@
|
||||
"snippetNone": "Нет",
|
||||
"noneAuthTitle": "Интерактивная аутентификация по клавиатуре",
|
||||
"noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию по клавиатуре при подключении к SSH-серверу.",
|
||||
"noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля."
|
||||
"noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля.",
|
||||
"forceKeyboardInteractive": "Принудительная клавиатурная аутентификация",
|
||||
"forceKeyboardInteractiveDesc": "Принудительно использует интерактивную аутентификацию по клавиатуре. Часто требуется для серверов с двухфакторной аутентификацией (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Переопределить имя пользователя учетных данных",
|
||||
"overrideCredentialUsernameDesc": "Используйте другое имя пользователя, отличное от того, что хранится в учетных данных. Это позволяет использовать одни и те же учетные данные с разными именами пользователей.",
|
||||
"jumpHosts": "Промежуточные хосты",
|
||||
"jumpHostsDescription": "Промежуточные хосты (также известные как бастионы) позволяют подключаться к целевому серверу через один или несколько промежуточных серверов. Это полезно для доступа к серверам за брандмауэрами или в частных сетях.",
|
||||
"jumpHostChain": "Цепочка промежуточных хостов",
|
||||
"addJumpHost": "Добавить промежуточный хост",
|
||||
"selectServer": "Выбрать сервер",
|
||||
"searchServers": "Поиск серверов...",
|
||||
"noServerFound": "Сервер не найден",
|
||||
"jumpHostsOrder": "Подключения будут выполнены в порядке: Промежуточный хост 1 → Промежуточный хост 2 → ... → Целевой сервер",
|
||||
"advancedAuthSettings": "Расширенные настройки аутентификации"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Терминал",
|
||||
|
||||
@@ -868,7 +868,18 @@
|
||||
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
|
||||
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
|
||||
"forceKeyboardInteractive": "强制键盘交互式认证",
|
||||
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。"
|
||||
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。",
|
||||
"overrideCredentialUsername": "覆盖凭据用户名",
|
||||
"overrideCredentialUsernameDesc": "使用与凭据中存储的用户名不同的用户名。这允许您对不同的用户名使用相同的凭据。",
|
||||
"jumpHosts": "跳板主机",
|
||||
"jumpHostsDescription": "跳板主机(也称为堡垒主机)允许您通过一个或多个中间服务器连接到目标服务器。这对于访问防火墙后或私有网络中的服务器很有用。",
|
||||
"jumpHostChain": "跳板主机链",
|
||||
"addJumpHost": "添加跳板主机",
|
||||
"selectServer": "选择服务器",
|
||||
"searchServers": "搜索服务器...",
|
||||
"noServerFound": "未找到服务器",
|
||||
"jumpHostsOrder": "连接将按顺序进行:跳板主机 1 → 跳板主机 2 → ... → 目标服务器",
|
||||
"advancedAuthSettings": "高级身份验证设置"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "终端",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useTabs,
|
||||
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
||||
import { CommandHistoryProvider } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
|
||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
@@ -31,7 +31,7 @@ function AppContent() {
|
||||
const { currentTab, tabs } = useTabs();
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(300);
|
||||
|
||||
const lastShiftPressTime = useRef(0);
|
||||
|
||||
@@ -159,7 +159,7 @@ function AppContent() {
|
||||
const showProfile = currentTabData?.type === "user_profile";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="h-screen w-screen overflow-hidden">
|
||||
<CommandPalette
|
||||
isOpen={isCommandPaletteOpen}
|
||||
setIsOpen={setIsCommandPaletteOpen}
|
||||
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -55,6 +63,7 @@ import {
|
||||
getSessions,
|
||||
revokeSession,
|
||||
revokeAllUserSessions,
|
||||
convertOIDCToPassword,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface AdminSettingsProps {
|
||||
@@ -66,7 +75,7 @@ interface AdminSettingsProps {
|
||||
export function AdminSettings({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
rightSidebarWidth = 300,
|
||||
}: AdminSettingsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
@@ -138,6 +147,16 @@ export function AdminSettings({
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
|
||||
const [convertUserDialogOpen, setConvertUserDialogOpen] =
|
||||
React.useState(false);
|
||||
const [convertTargetUser, setConvertTargetUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
const [convertPassword, setConvertPassword] = React.useState("");
|
||||
const [convertTotpCode, setConvertTotpCode] = React.useState("");
|
||||
const [convertLoading, setConvertLoading] = React.useState(false);
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
@@ -636,6 +655,57 @@ export function AdminSettings({
|
||||
);
|
||||
};
|
||||
|
||||
const handleConvertOIDCUser = (user: { id: string; username: string }) => {
|
||||
setConvertTargetUser(user);
|
||||
setConvertPassword("");
|
||||
setConvertTotpCode("");
|
||||
setConvertUserDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConvertSubmit = async () => {
|
||||
if (!convertTargetUser || !convertPassword) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (convertPassword.length < 8) {
|
||||
toast.error("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setConvertLoading(true);
|
||||
try {
|
||||
const result = await convertOIDCToPassword(
|
||||
convertTargetUser.id,
|
||||
convertPassword,
|
||||
convertTotpCode || undefined,
|
||||
);
|
||||
|
||||
toast.success(
|
||||
result.message ||
|
||||
`User ${convertTargetUser.username} converted to password authentication`,
|
||||
);
|
||||
setConvertUserDialogOpen(false);
|
||||
setConvertPassword("");
|
||||
setConvertTotpCode("");
|
||||
setConvertTargetUser(null);
|
||||
fetchUsers();
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
if (err.response?.data?.code === "TOTP_REQUIRED") {
|
||||
toast.error("TOTP code is required for this user");
|
||||
} else {
|
||||
toast.error(
|
||||
err.response?.data?.error || "Failed to convert user account",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setConvertLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
@@ -1030,15 +1100,35 @@ export function AdminSettings({
|
||||
: t("admin.local")}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<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 className="flex gap-2">
|
||||
{user.is_oidc && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleConvertOIDCUser({
|
||||
id: user.id,
|
||||
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>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -1414,6 +1504,79 @@ export function AdminSettings({
|
||||
</Tabs>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import {
|
||||
ChartLine,
|
||||
Clock,
|
||||
@@ -61,7 +62,7 @@ export function Dashboard({
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
rightSidebarWidth = 300,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
@@ -357,6 +358,11 @@ export function Dashboard({
|
||||
{t("dashboard.title")}
|
||||
</div>
|
||||
<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
|
||||
className="font-semibold"
|
||||
variant="outline"
|
||||
|
||||
@@ -19,7 +19,7 @@ export function HostManager({
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
rightSidebarWidth = 300,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
|
||||
@@ -81,6 +81,94 @@ import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx"
|
||||
import type { TerminalConfig } from "@/types";
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -722,8 +810,13 @@ export function HostManagerEditor({
|
||||
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
|
||||
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||
refreshServerPolling();
|
||||
// Notify the stats server to start/update polling for this specific host
|
||||
if (savedHost?.id) {
|
||||
const { notifyHostCreatedOrUpdated } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
notifyHostCreatedOrUpdated(savedHost.id);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToSaveHost"));
|
||||
} finally {
|
||||
@@ -1406,169 +1499,102 @@ export function HostManagerEditor({
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</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" />
|
||||
<FormLabel className="mb-3 font-bold">
|
||||
{t("hosts.jumpHosts")}
|
||||
</FormLabel>
|
||||
<Alert className="mt-2 mb-4">
|
||||
<AlertDescription>
|
||||
{t("hosts.jumpHostsDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jumpHosts"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("hosts.jumpHostChain")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
{field.value.map((jumpHost, index) => {
|
||||
const selectedHost = hosts.find(
|
||||
(h) => h.id === jumpHost.hostId,
|
||||
);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="advanced-auth">
|
||||
<AccordionTrigger>
|
||||
{t("hosts.advancedAuthSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="forceKeyboardInteractive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
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={() => {
|
||||
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>
|
||||
<AccordionItem value="jump-hosts">
|
||||
<AccordionTrigger>
|
||||
{t("hosts.jumpHosts")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hosts.jumpHostsDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jumpHosts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.jumpHostChain")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
{field.value.map((jumpHost, index) => (
|
||||
<JumpHostItem
|
||||
key={index}
|
||||
jumpHost={jumpHost}
|
||||
index={index}
|
||||
hosts={hosts}
|
||||
editingHost={editingHost}
|
||||
onUpdate={(hostId) => {
|
||||
const newJumpHosts = [...field.value];
|
||||
newJumpHosts[index] = { hostId };
|
||||
field.onChange(newJumpHosts);
|
||||
}}
|
||||
onRemove={() => {
|
||||
const newJumpHosts = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newJumpHosts);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newJumpHosts = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newJumpHosts);
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{ hostId: 0 },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("hosts.addJumpHost")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange([...field.value, { hostId: 0 }]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("hosts.addJumpHost")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.jumpHostsOrder")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.jumpHostsOrder")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
<TabsContent value="terminal" className="space-y-1">
|
||||
<FormField
|
||||
|
||||
@@ -103,8 +103,15 @@ export function Server({
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
// Reset state when switching to a different host
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setMetricsHistory([]);
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const renderWidget = (widgetType: WidgetType) => {
|
||||
switch (widgetType) {
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
import type { TerminalConfig } from "@/types";
|
||||
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
|
||||
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
|
||||
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
|
||||
import { CommandAutocomplete } from "./CommandAutocomplete";
|
||||
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface HostConfig {
|
||||
@@ -1421,14 +1421,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
||||
if (!isVisible && isFitted) {
|
||||
setIsFitted(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFitted(false);
|
||||
|
||||
// Don't set isFitted to false - keep terminal visible during resize
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
|
||||
@@ -1467,8 +1463,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ref={xtermRef}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
visibility:
|
||||
isReady && !isConnecting && isFitted ? "visible" : "hidden",
|
||||
opacity: isReady && !isConnecting && isFitted ? 1 : 0,
|
||||
transition: "opacity 100ms ease-in-out",
|
||||
pointerEvents:
|
||||
isReady && !isConnecting && isFitted ? "auto" : "none",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
suggestions: string[];
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
RotateCcw,
|
||||
Search,
|
||||
Loader2,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
deleteSnippet,
|
||||
getCookie,
|
||||
setCookie,
|
||||
getCommandHistory,
|
||||
deleteCommandFromHistory,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import type { Snippet, SnippetData } from "../../../../types";
|
||||
@@ -57,6 +60,10 @@ interface TabData {
|
||||
sendInput?: (data: string) => void;
|
||||
};
|
||||
};
|
||||
hostConfig?: {
|
||||
id: number;
|
||||
};
|
||||
isActive?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -66,30 +73,25 @@ interface SSHUtilitySidebarProps {
|
||||
onSnippetExecute: (content: string) => void;
|
||||
sidebarWidth: number;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
commandHistory?: string[];
|
||||
onSelectCommand?: (command: string) => void;
|
||||
onDeleteCommand?: (command: string) => void;
|
||||
isHistoryLoading?: boolean;
|
||||
initialTab?: string;
|
||||
onTabChange?: () => void;
|
||||
}
|
||||
|
||||
export function SSHUtilitySidebar({
|
||||
export function SSHToolsSidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSnippetExecute,
|
||||
sidebarWidth,
|
||||
setSidebarWidth,
|
||||
commandHistory = [],
|
||||
onSelectCommand,
|
||||
onDeleteCommand,
|
||||
isHistoryLoading = false,
|
||||
initialTab,
|
||||
onTabChange,
|
||||
}: SSHUtilitySidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { tabs } = useTabs() as { tabs: TabData[] };
|
||||
const { tabs, currentTab } = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number | null;
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
|
||||
|
||||
// Update active tab when initialTab changes
|
||||
@@ -133,8 +135,9 @@ export function SSHUtilitySidebar({
|
||||
);
|
||||
|
||||
// Command History state
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
|
||||
// Resize state
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
@@ -142,6 +145,31 @@ export function SSHUtilitySidebar({
|
||||
const startWidthRef = React.useRef<number>(sidebarWidth);
|
||||
|
||||
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
|
||||
const activeUiTab = tabs.find((tab) => tab.id === currentTab);
|
||||
const activeTerminal =
|
||||
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
|
||||
const activeTerminalHostId = activeTerminal?.hostConfig?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "command-history") {
|
||||
if (activeTerminalHostId) {
|
||||
setIsHistoryLoading(true);
|
||||
getCommandHistory(activeTerminalHostId)
|
||||
.then((history) => {
|
||||
setCommandHistory(history);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch command history", err);
|
||||
setCommandHistory([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsHistoryLoading(false);
|
||||
});
|
||||
} else {
|
||||
setCommandHistory([]);
|
||||
}
|
||||
}
|
||||
}, [isOpen, activeTab, activeTerminalHostId]);
|
||||
|
||||
// Filter command history based on search query
|
||||
const filteredCommands = searchQuery
|
||||
@@ -158,6 +186,21 @@ export function SSHUtilitySidebar({
|
||||
);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
// Handle window resize to adjust sidebar width
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
||||
if (sidebarWidth > maxWidth) {
|
||||
setSidebarWidth(Math.max(minWidth, maxWidth));
|
||||
} else if (sidebarWidth < minWidth) {
|
||||
setSidebarWidth(minWidth);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [sidebarWidth, setSidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "snippets") {
|
||||
fetchSnippets();
|
||||
@@ -179,8 +222,8 @@ export function SSHUtilitySidebar({
|
||||
if (startXRef.current == null) return;
|
||||
const dx = startXRef.current - e.clientX; // Reversed because we're on the right
|
||||
const newWidth = Math.round(startWidthRef.current + dx);
|
||||
const minWidth = 300;
|
||||
const maxWidth = Math.round(window.innerWidth * 0.5);
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
const maxWidth = Math.round(window.innerWidth * 0.3);
|
||||
|
||||
let finalWidth = newWidth;
|
||||
if (newWidth < minWidth) {
|
||||
@@ -495,25 +538,34 @@ export function SSHUtilitySidebar({
|
||||
|
||||
// Command History handlers
|
||||
const handleCommandSelect = (command: string) => {
|
||||
if (onSelectCommand) {
|
||||
onSelectCommand(command);
|
||||
if (activeTerminal?.terminalRef?.current?.sendInput) {
|
||||
activeTerminal.terminalRef.current.sendInput(command);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommandDelete = (command: string) => {
|
||||
if (onDeleteCommand) {
|
||||
if (activeTerminalHostId) {
|
||||
confirmWithToast(
|
||||
t("commandHistory.deleteConfirmDescription", {
|
||||
defaultValue: `Delete "${command}" from history?`,
|
||||
command,
|
||||
}),
|
||||
() => {
|
||||
onDeleteCommand(command);
|
||||
toast.success(
|
||||
t("commandHistory.deleteSuccess", {
|
||||
defaultValue: "Command deleted from history",
|
||||
}),
|
||||
);
|
||||
async () => {
|
||||
try {
|
||||
await deleteCommandFromHistory(activeTerminalHostId, command);
|
||||
setCommandHistory((prev) => prev.filter((c) => c !== command));
|
||||
toast.success(
|
||||
t("commandHistory.deleteSuccess", {
|
||||
defaultValue: "Command deleted from history",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error(
|
||||
t("commandHistory.deleteFailed", {
|
||||
defaultValue: "Failed to delete command.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
@@ -542,7 +594,7 @@ export function SSHUtilitySidebar({
|
||||
<div className="absolute right-5 flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSidebarWidth(400)}
|
||||
onClick={() => setSidebarWidth(300)}
|
||||
className="w-[28px] h-[28px]"
|
||||
title="Reset sidebar width"
|
||||
>
|
||||
@@ -855,7 +907,6 @@ export function SSHUtilitySidebar({
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSelectedCommandIndex(0);
|
||||
}}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
@@ -874,7 +925,7 @@ export function SSHUtilitySidebar({
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{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} />
|
||||
<span>
|
||||
{t("commandHistory.loading", {
|
||||
@@ -882,6 +933,21 @@ export function SSHUtilitySidebar({
|
||||
})}
|
||||
</span>
|
||||
</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 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? (
|
||||
@@ -909,14 +975,14 @@ export function SSHUtilitySidebar({
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.emptyHint", {
|
||||
defaultValue:
|
||||
"Execute commands to build your history",
|
||||
"Execute commands in the active terminal to build its history.",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</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) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -929,42 +995,26 @@ export function SSHUtilitySidebar({
|
||||
>
|
||||
{command}
|
||||
</span>
|
||||
{onDeleteCommand && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCommandDelete(command);
|
||||
}}
|
||||
title={t("commandHistory.deleteTooltip", {
|
||||
defaultValue: "Delete command",
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCommandDelete(command);
|
||||
}}
|
||||
title={t("commandHistory.deleteTooltip", {
|
||||
defaultValue: "Delete command",
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</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>
|
||||
</Tabs>
|
||||
</SidebarContent>
|
||||
@@ -42,7 +42,7 @@ interface TerminalViewProps {
|
||||
export function AppView({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
rightSidebarWidth = 300,
|
||||
}: TerminalViewProps): React.ReactElement {
|
||||
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as {
|
||||
tabs: TabData[];
|
||||
@@ -70,16 +70,17 @@ export function AppView({
|
||||
);
|
||||
const [ready, setReady] = useState<boolean>(true);
|
||||
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> = {};
|
||||
Object.entries(panelRefs.current).forEach(([id, el]) => {
|
||||
if (el) next[id] = el.getBoundingClientRect();
|
||||
});
|
||||
setPanelRects(next);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fitActiveAndNotify = () => {
|
||||
const fitActiveAndNotify = React.useCallback(() => {
|
||||
const visibleIds: number[] = [];
|
||||
if (allSplitScreenTab.length === 0) {
|
||||
if (currentTab) visibleIds.push(currentTab);
|
||||
@@ -95,10 +96,10 @@ export function AppView({
|
||||
if (ref?.refresh) ref.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [allSplitScreenTab, currentTab, terminalTabs]);
|
||||
|
||||
const layoutScheduleRef = useRef<number | null>(null);
|
||||
const scheduleMeasureAndFit = () => {
|
||||
const scheduleMeasureAndFit = React.useCallback(() => {
|
||||
if (layoutScheduleRef.current)
|
||||
cancelAnimationFrame(layoutScheduleRef.current);
|
||||
layoutScheduleRef.current = requestAnimationFrame(() => {
|
||||
@@ -107,18 +108,17 @@ export function AppView({
|
||||
fitActiveAndNotify();
|
||||
});
|
||||
});
|
||||
};
|
||||
}, [updatePanelRects, fitActiveAndNotify]);
|
||||
|
||||
const hideThenFit = () => {
|
||||
setReady(false);
|
||||
const hideThenFit = React.useCallback(() => {
|
||||
// Don't hide terminals, just fit them immediately
|
||||
requestAnimationFrame(() => {
|
||||
updatePanelRects();
|
||||
requestAnimationFrame(() => {
|
||||
fitActiveAndNotify();
|
||||
setReady(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
}, [updatePanelRects, fitActiveAndNotify]);
|
||||
|
||||
const prevStateRef = useRef({
|
||||
terminalTabsLength: terminalTabs.length,
|
||||
@@ -158,11 +158,20 @@ export function AppView({
|
||||
terminalTabs.length,
|
||||
allSplitScreenTab.join(","),
|
||||
terminalTabs,
|
||||
hideThenFit,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
scheduleMeasureAndFit();
|
||||
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
|
||||
}, [
|
||||
scheduleMeasureAndFit,
|
||||
allSplitScreenTab.length,
|
||||
isTopbarOpen,
|
||||
sidebarState,
|
||||
resetKey,
|
||||
rightSidebarOpen,
|
||||
rightSidebarWidth,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const roContainer = containerRef.current
|
||||
@@ -174,7 +183,7 @@ export function AppView({
|
||||
if (containerRef.current && roContainer)
|
||||
roContainer.observe(containerRef.current);
|
||||
return () => roContainer?.disconnect();
|
||||
}, []);
|
||||
}, [updatePanelRects, fitActiveAndNotify]);
|
||||
|
||||
useEffect(() => {
|
||||
const onWinResize = () => {
|
||||
@@ -183,7 +192,7 @@ export function AppView({
|
||||
};
|
||||
window.addEventListener("resize", onWinResize);
|
||||
return () => window.removeEventListener("resize", onWinResize);
|
||||
}, []);
|
||||
}, [updatePanelRects, fitActiveAndNotify]);
|
||||
|
||||
const HEADER_H = 28;
|
||||
|
||||
@@ -208,33 +217,39 @@ export function AppView({
|
||||
|
||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||
const isFileManagerTab = mainTab.type === "file_manager";
|
||||
styles[mainTab.id] = {
|
||||
position: "absolute",
|
||||
const newStyle = {
|
||||
position: "absolute" as const,
|
||||
top: isFileManagerTab ? 0 : 4,
|
||||
left: isFileManagerTab ? 0 : 4,
|
||||
right: isFileManagerTab ? 0 : 4,
|
||||
bottom: isFileManagerTab ? 0 : 4,
|
||||
zIndex: 20,
|
||||
display: "block",
|
||||
pointerEvents: "auto",
|
||||
opacity: ready ? 1 : 0,
|
||||
display: "block" as const,
|
||||
pointerEvents: "auto" as const,
|
||||
opacity: 1,
|
||||
transition: "opacity 150ms ease-in-out",
|
||||
};
|
||||
styles[mainTab.id] = newStyle;
|
||||
previousStylesRef.current[mainTab.id] = newStyle;
|
||||
} else {
|
||||
layoutTabs.forEach((t: TabData) => {
|
||||
const rect = panelRects[String(t.id)];
|
||||
const parentRect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect && parentRect) {
|
||||
styles[t.id] = {
|
||||
position: "absolute",
|
||||
const newStyle = {
|
||||
position: "absolute" as const,
|
||||
top: rect.top - parentRect.top + HEADER_H + 4,
|
||||
left: rect.left - parentRect.left + 4,
|
||||
width: rect.width - 8,
|
||||
height: rect.height - HEADER_H - 8,
|
||||
zIndex: 20,
|
||||
display: "block",
|
||||
pointerEvents: "auto",
|
||||
opacity: ready ? 1 : 0,
|
||||
display: "block" as const,
|
||||
pointerEvents: "auto" as const,
|
||||
opacity: 1,
|
||||
transition: "opacity 150ms ease-in-out",
|
||||
};
|
||||
styles[t.id] = newStyle;
|
||||
previousStylesRef.current[t.id] = newStyle;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -248,17 +263,31 @@ export function AppView({
|
||||
const isVisible =
|
||||
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
|
||||
|
||||
// Use previous style if available to maintain position
|
||||
const previousStyle = previousStylesRef.current[t.id];
|
||||
|
||||
// For non-split screen tabs, always use the standard position
|
||||
const isFileManagerTab = t.type === "file_manager";
|
||||
const standardStyle = {
|
||||
position: "absolute" as const,
|
||||
top: isFileManagerTab ? 0 : 4,
|
||||
left: isFileManagerTab ? 0 : 4,
|
||||
right: isFileManagerTab ? 0 : 4,
|
||||
bottom: isFileManagerTab ? 0 : 4,
|
||||
};
|
||||
|
||||
const finalStyle: React.CSSProperties = hasStyle
|
||||
? { ...styles[t.id], overflow: "hidden" }
|
||||
: ({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
visibility: "hidden",
|
||||
...(previousStyle || standardStyle),
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
transition: "opacity 150ms ease-in-out",
|
||||
overflow: "hidden",
|
||||
} as React.CSSProperties);
|
||||
|
||||
const effectiveVisible = isVisible && ready;
|
||||
const effectiveVisible = isVisible;
|
||||
|
||||
const isTerminal = t.type === "terminal";
|
||||
const terminalConfig = {
|
||||
|
||||
@@ -289,7 +289,11 @@ export function LeftSidebar({
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||
const saved = localStorage.getItem("leftSidebarWidth");
|
||||
return saved !== null ? parseInt(saved, 10) : 250;
|
||||
const defaultWidth = 250;
|
||||
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
|
||||
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
||||
return Math.min(savedWidth, Math.max(minWidth, maxWidth));
|
||||
});
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
@@ -300,6 +304,20 @@ export function LeftSidebar({
|
||||
localStorage.setItem("leftSidebarWidth", String(sidebarWidth));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
||||
if (sidebarWidth > maxWidth) {
|
||||
setSidebarWidth(Math.max(minWidth, maxWidth));
|
||||
} else if (sidebarWidth < minWidth) {
|
||||
setSidebarWidth(minWidth);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
@@ -314,8 +332,8 @@ export function LeftSidebar({
|
||||
if (startXRef.current == null) return;
|
||||
const dx = e.clientX - startXRef.current;
|
||||
const newWidth = Math.round(startWidthRef.current + dx);
|
||||
const minWidth = 200;
|
||||
const maxWidth = Math.round(window.innerWidth * 0.5);
|
||||
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
|
||||
const maxWidth = Math.round(window.innerWidth * 0.3);
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
setSidebarWidth(newWidth);
|
||||
} else if (newWidth < minWidth) {
|
||||
@@ -394,14 +412,14 @@ export function LeftSidebar({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<div className="h-screen w-screen overflow-hidden">
|
||||
<SidebarProvider
|
||||
open={isSidebarOpen}
|
||||
style={
|
||||
{ "--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">
|
||||
<SidebarHeader>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||
|
||||
@@ -25,7 +25,7 @@ export function TOTPDialog({
|
||||
if (!isOpen) return null;
|
||||
|
||||
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
|
||||
className="absolute inset-0 bg-dark-bg rounded-md"
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
|
||||
import { SSHUtilitySidebar } from "@/ui/desktop/apps/tools/SSHUtilitySidebar.tsx";
|
||||
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
|
||||
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
|
||||
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
@@ -62,26 +62,46 @@ export function TopNavbar({
|
||||
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
|
||||
const saved = localStorage.getItem("rightSidebarWidth");
|
||||
return saved !== null ? parseInt(saved, 10) : 350;
|
||||
const defaultWidth = 350;
|
||||
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
||||
return Math.min(savedWidth, Math.max(minWidth, maxWidth));
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem("rightSidebarWidth", String(rightSidebarWidth));
|
||||
}, [rightSidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
||||
if (rightSidebarWidth > maxWidth) {
|
||||
setRightSidebarWidth(Math.max(minWidth, maxWidth));
|
||||
} else if (rightSidebarWidth < minWidth) {
|
||||
setRightSidebarWidth(minWidth);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [rightSidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (onRightSidebarStateChange) {
|
||||
onRightSidebarStateChange(toolsSidebarOpen, rightSidebarWidth);
|
||||
}
|
||||
}, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]);
|
||||
|
||||
const openCommandHistorySidebar = React.useCallback(() => {
|
||||
setToolsSidebarOpen(true);
|
||||
setCommandHistoryTabActive(true);
|
||||
}, []);
|
||||
|
||||
// Register function to open command history sidebar
|
||||
React.useEffect(() => {
|
||||
commandHistory.setOpenCommandHistory(() => {
|
||||
setToolsSidebarOpen(true);
|
||||
setCommandHistoryTabActive(true);
|
||||
});
|
||||
}, [commandHistory]);
|
||||
commandHistory.setOpenCommandHistory(openCommandHistorySidebar);
|
||||
}, [commandHistory, openCommandHistorySidebar]);
|
||||
|
||||
const rightPosition = toolsSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
@@ -540,7 +560,7 @@ export function TopNavbar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SSHUtilitySidebar
|
||||
<SSHToolsSidebar
|
||||
isOpen={toolsSidebarOpen}
|
||||
onClose={() => setToolsSidebarOpen(false)}
|
||||
onSnippetExecute={handleSnippetExecute}
|
||||
|
||||
@@ -74,7 +74,7 @@ async function handleLogout() {
|
||||
export function UserProfile({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
rightSidebarWidth = 300,
|
||||
}: UserProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
@@ -2998,3 +3009,27 @@ export async function clearCommandHistory(
|
||||
throw handleApiError(error, "clear command history");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC TO PASSWORD CONVERSION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert an OIDC user to a password-based user
|
||||
*/
|
||||
export async function convertOIDCToPassword(
|
||||
targetUserId: string,
|
||||
newPassword: string,
|
||||
totpCode?: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/convert-oidc-to-password", {
|
||||
targetUserId,
|
||||
newPassword,
|
||||
totpCode,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "convert OIDC user to password");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user