v1.9.0 #437

Merged
LukeGus merged 33 commits from dev-1.9.0 into main 2025-11-17 15:46:05 +00:00
41 changed files with 88 additions and 412 deletions
Showing only changes of commit 20ef98957d - Show all commits

View File

@@ -96,12 +96,6 @@ async function initializeDatabaseAsync(): Promise<void> {
});
try {
databaseLogger.info(
"Generating diagnostic information for database encryption failure",
{
operation: "db_encryption_diagnostic",
},
);
const diagnosticInfo =
DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath);
databaseLogger.error(

View File

@@ -279,7 +279,6 @@ router.post(
}
try {
// Get the snippet
const snippetResult = await db
.select()
.from(snippets)
@@ -296,11 +295,9 @@ router.post(
const snippet = snippetResult[0];
// Import SSH connection utilities
const { Client } = await import("ssh2");
const { sshData, sshCredentials } = await import("../db/schema.js");
// Get host configuration using SimpleDBOps to decrypt credentials
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
const hostResult = await SimpleDBOps.select(
@@ -320,7 +317,6 @@ router.post(
const host = hostResult[0];
// Resolve credentials if needed
let password = host.password;
let privateKey = host.key;
let passphrase = host.key_password;
@@ -352,7 +348,6 @@ router.post(
}
}
// Create SSH connection
const conn = new Client();
let output = "";
let errorOutput = "";
@@ -400,7 +395,6 @@ router.post(
reject(err);
});
// Connect to SSH
const config: any = {
host: host.ip,
port: host.port,
@@ -471,7 +465,6 @@ router.post(
},
};
// Set auth based on authType (like terminal.ts does)
if (authType === "password" && password) {
config.password = password;
} else if (authType === "key" && privateKey) {
@@ -484,10 +477,8 @@ router.post(
config.passphrase = passphrase;
}
} else if (password) {
// Fallback: if authType not set but password exists
config.password = password;
} else if (privateKey) {
// Fallback: if authType not set but key exists
const cleanKey = (privateKey as string)
.trim()
.replace(/\r\n/g, "\n")

View File

@@ -361,7 +361,6 @@ router.post(
},
);
// Notify stats server to start polling this host
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;
@@ -603,7 +602,6 @@ 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;
@@ -893,7 +891,6 @@ router.delete(
const numericHostId = Number(hostId);
// Delete related records first to avoid foreign key constraint errors
await db
.delete(fileManagerRecent)
.where(
@@ -948,7 +945,6 @@ router.delete(
),
);
// Now delete the host itself
await db
.delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -966,7 +962,6 @@ router.delete(
},
);
// Notify stats server to stop polling this host
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;
@@ -1553,7 +1548,6 @@ router.put(
DatabaseSaveTrigger.triggerSave("folder_rename");
// Also update folder metadata if exists
await db
.update(sshFolders)
.set({
@@ -1620,7 +1614,6 @@ router.put(
}
try {
// Check if folder metadata exists
const existing = await db
.select()
.from(sshFolders)
@@ -1628,7 +1621,6 @@ router.put(
.limit(1);
if (existing.length > 0) {
// Update existing
await db
.update(sshFolders)
.set({
@@ -1638,7 +1630,6 @@ router.put(
})
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)));
} else {
// Create new
await db.insert(sshFolders).values({
userId,
name,
@@ -1677,7 +1668,6 @@ router.delete(
}
try {
// Get all hosts in the folder
const hostsToDelete = await db
.select()
.from(sshData)
@@ -1690,12 +1680,10 @@ router.delete(
});
}
// Delete all hosts
await db
.delete(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
// Delete folder metadata
await db
.delete(sshFolders)
.where(
@@ -1704,14 +1692,6 @@ router.delete(
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
sshLogger.info("Deleted all hosts in folder", {
operation: "delete_folder_hosts",
userId,
folderName,
deletedCount: hostsToDelete.length,
});
// Notify stats server to stop polling these hosts
try {
const axios = (await import("axios")).default;
const statsPort = process.env.STATS_PORT || 30005;

View File

@@ -85,8 +85,6 @@ router.get(
}
try {
// Get unique commands for this host, ordered by most recent
// Use DISTINCT to avoid duplicates, but keep the most recent occurrence
const result = await db
.selectDistinct({ command: commandHistory.command })
.from(commandHistory)
@@ -97,9 +95,8 @@ router.get(
),
)
.orderBy(desc(commandHistory.executedAt))
.limit(500); // Limit to last 500 unique commands
.limit(500);
// Further deduplicate in case DISTINCT didn't work perfectly
const uniqueCommands = Array.from(new Set(result.map((r) => r.command)));
authLogger.info(`Fetched command history for host ${hostId}`, {
@@ -142,7 +139,6 @@ router.post(
try {
const hostIdNum = parseInt(hostId, 10);
// Delete all instances of this command for this user and host
await db
.delete(commandHistory)
.where(

View File

@@ -763,7 +763,6 @@ router.get("/oidc/callback", async (req, res) => {
.get();
isFirstUser = ((countResult as { count?: number })?.count || 0) === 0;
// Check if registration is allowed (unless this is the first user)
if (!isFirstUser) {
try {
const regRow = db.$client
@@ -822,8 +821,8 @@ router.get("/oidc/callback", async (req, res) => {
try {
const sessionDurationMs =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000 // 30 days
: 7 * 24 * 60 * 60 * 1000; // 7 days
? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000;
await authManager.registerOIDCUser(id, sessionDurationMs);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, id));
@@ -862,7 +861,6 @@ router.get("/oidc/callback", async (req, res) => {
const userRecord = user[0];
// For all OIDC logins (including dual-auth), use OIDC encryption
try {
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
} catch (setupError) {
@@ -901,7 +899,6 @@ router.get("/oidc/callback", async (req, res) => {
? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000;
// Clear any existing JWT cookie first to prevent conflicts
res.clearCookie("jwt", authManager.getSecureCookieOptions(req));
return res
@@ -941,7 +938,6 @@ router.post("/login", async (req, res) => {
return res.status(400).json({ error: "Invalid username or password" });
}
// Check rate limiting
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
if (lockStatus.locked) {
authLogger.warn("Login attempt blocked due to rate limiting", {
@@ -995,8 +991,6 @@ router.post("/login", async (req, res) => {
const userRecord = user[0];
// Only reject if user is OIDC-only (has no password set)
// Empty string "" is treated as no password
if (
userRecord.is_oidc &&
(!userRecord.password_hash || userRecord.password_hash.trim() === "")
@@ -1040,17 +1034,13 @@ router.post("/login", async (req, res) => {
const deviceInfo = parseUserAgent(req);
// For dual-auth users (has both password and OIDC), use OIDC encryption
// For password-only users, use password-based encryption
let dataUnlocked = false;
if (userRecord.is_oidc) {
// Dual-auth user: verify password then use OIDC encryption
dataUnlocked = await authManager.authenticateOIDCUser(
userRecord.id,
deviceInfo.type,
);
} else {
// Password-only user: use password-based encryption
dataUnlocked = await authManager.authenticateUser(
userRecord.id,
password,
@@ -1079,7 +1069,6 @@ router.post("/login", async (req, res) => {
deviceInfo: deviceInfo.deviceInfo,
});
// Reset rate limiter on successful login
loginRateLimiter.resetAttempts(clientIp, username);
authLogger.success(`User logged in successfully: ${username}`, {
@@ -2255,7 +2244,6 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
const targetUserId = targetUser[0].id;
try {
// Delete all user-related data to avoid foreign key constraints
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, targetUserId));
@@ -2588,7 +2576,6 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
}
try {
// Verify admin permissions
const adminUser = await db
.select()
.from(users)
@@ -2597,7 +2584,6 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
return res.status(403).json({ error: "Admin access required" });
}
// Get OIDC user
const oidcUserRecords = await db
.select()
.from(users)
@@ -2608,14 +2594,12 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
const oidcUser = oidcUserRecords[0];
// Verify user is OIDC
if (!oidcUser.is_oidc) {
return res.status(400).json({
error: "Source user is not an OIDC user",
});
}
// Get target password user
const targetUserRecords = await db
.select()
.from(users)
@@ -2626,14 +2610,12 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
const targetUser = targetUserRecords[0];
// Verify target user has password authentication
if (targetUser.is_oidc || !targetUser.password_hash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
// Check if target user already has OIDC configured
if (targetUser.client_id && targetUser.oidc_identifier) {
return res.status(400).json({
error: "Target user already has OIDC authentication configured",
@@ -2649,11 +2631,10 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
adminUserId,
});
// Copy OIDC configuration from OIDC user to target password user
await db
.update(users)
.set({
is_oidc: true, // Enable OIDC login for this account
is_oidc: true,
oidc_identifier: oidcUser.oidc_identifier,
client_id: oidcUser.client_id,
client_secret: oidcUser.client_secret,
@@ -2666,14 +2647,8 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
})
.where(eq(users.id, targetUser.id));
// Re-encrypt the user's DEK with OIDC system key for dual-auth support
// This allows OIDC login to decrypt user data without requiring password
try {
await authManager.convertToOIDCEncryption(targetUser.id);
authLogger.info("Converted user encryption to OIDC for dual-auth", {
operation: "link_convert_encryption",
userId: targetUser.id,
});
} catch (encryptionError) {
authLogger.error(
"Failed to convert encryption to OIDC during linking",
@@ -2683,7 +2658,6 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
userId: targetUser.id,
},
);
// Rollback the OIDC configuration
await db
.update(users)
.set({
@@ -2710,19 +2684,15 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
});
}
// Revoke all sessions for the OIDC user before deletion
await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId);
// Delete OIDC user's recent activity first (to avoid NOT NULL constraint issues)
await db
.delete(recentActivity)
.where(eq(recentActivity.userId, oidcUserId));
// Delete the OIDC user (CASCADE will delete related data: hosts, credentials, sessions, etc.)
await db.delete(users).where(eq(users.id, oidcUserId));
// Clean up OIDC user's settings
db.$client
.prepare("DELETE FROM settings WHERE key LIKE ?")
.run(`user_%_${oidcUserId}`);

View File

@@ -261,7 +261,7 @@ interface SSHSession {
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number; // Track number of active operations to prevent cleanup mid-operation
activeOperations: number;
}
interface PendingTOTPSession {
@@ -286,7 +286,6 @@ const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
// Don't cleanup if there are active operations
if (session.activeOperations > 0) {
fileLogger.warn(
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
@@ -296,7 +295,6 @@ function cleanupSession(sessionId: string) {
activeOperations: session.activeOperations,
},
);
// Reschedule cleanup
scheduleSessionCleanup(sessionId);
return;
}
@@ -577,7 +575,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0, // Initialize active operations counter
activeOperations: 0,
};
scheduleSessionCleanup(sessionId);
res.json({ status: "success", message: "SSH connection established" });
@@ -932,7 +930,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0, // Initialize active operations counter
activeOperations: 0,
};
scheduleSessionCleanup(sessionId);
@@ -1076,12 +1074,12 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
}
sshConn.lastActive = Date.now();
sshConn.activeOperations++; // Track operation start
sshConn.activeOperations++;
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
sshConn.activeOperations--; // Decrement on error
sshConn.activeOperations--;
fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });
}
@@ -1098,7 +1096,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
});
stream.on("close", (code) => {
sshConn.activeOperations--; // Decrement when operation completes
sshConn.activeOperations--;
if (code !== 0) {
fileLogger.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
@@ -2892,7 +2890,6 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
const fileName = archivePath.split("/").pop() || "";
const fileExt = fileName.toLowerCase();
// Determine extraction command based on file extension
let extractCommand = "";
const targetPath =
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
@@ -2970,13 +2967,11 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
error: errorOutput,
});
// Check if command not found
let friendlyError = errorOutput || "Failed to extract archive";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
// Detect which command is missing based on file extension
let missingCmd = "";
let installHint = "";
@@ -3076,15 +3071,12 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Determine compression format
const compressionFormat = format || "zip"; // Default to zip
const compressionFormat = format || "zip";
let compressCommand = "";
// Get the directory where the first file is located
const firstPath = paths[0];
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
// Extract just the file/folder names for the command
const fileNames = paths
.map((p) => {
const name = p.split("/").pop();
@@ -3092,7 +3084,6 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
})
.join(" ");
// Construct archive path
let archivePath = "";
if (archiveName.includes("/")) {
archivePath = archiveName;
@@ -3103,7 +3094,6 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
}
if (compressionFormat === "zip") {
// Use zip command - need to cd to directory first
compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`;
@@ -3170,7 +3160,6 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
error: errorOutput,
});
// Check if command not found
let friendlyError = errorOutput || "Failed to compress files";
if (
errorOutput.includes("command not found") ||

View File

@@ -652,15 +652,12 @@ class PollingManager {
let parsed: StatsConfig;
// If it's already an object, use it directly
if (typeof statsConfigStr === "object") {
parsed = statsConfigStr;
} else {
// Otherwise, parse as JSON string (may be double-encoded)
try {
let temp: any = JSON.parse(statsConfigStr);
// Check if we got a string back (double-encoded JSON)
if (typeof temp === "string") {
temp = JSON.parse(temp);
}
@@ -678,7 +675,6 @@ class PollingManager {
}
}
// Merge with defaults, but prioritize the provided values
const result = { ...DEFAULT_STATS_CONFIG, ...parsed };
return result;
@@ -689,7 +685,6 @@ class PollingManager {
const existingConfig = this.pollingConfigs.get(host.id);
// Always clear existing timers first
if (existingConfig) {
if (existingConfig.statusTimer) {
clearInterval(existingConfig.statusTimer);
@@ -701,7 +696,6 @@ 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);
@@ -720,7 +714,6 @@ class PollingManager {
this.pollHostStatus(host);
config.statusTimer = setInterval(() => {
// Always get the latest config to check if polling is still enabled
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
this.pollHostStatus(latestConfig.host);
@@ -733,11 +726,9 @@ class PollingManager {
if (statsConfig.metricsEnabled) {
const intervalMs = statsConfig.metricsInterval * 1000;
// Poll immediately, but only if metrics are actually enabled
this.pollHostMetrics(host);
config.metricsTimer = setInterval(() => {
// Always get the latest config to check if polling is still enabled
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
this.pollHostMetrics(latestConfig.host);
@@ -747,7 +738,6 @@ class PollingManager {
this.metricsStore.delete(host.id);
}
// Set the config with the new timers
this.pollingConfigs.set(host.id, config);
}
@@ -769,14 +759,11 @@ class PollingManager {
}
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
// Double-check that metrics are still enabled before collecting
// Always get the LATEST config from the Map, not from the closure
const config = this.pollingConfigs.get(host.id);
if (!config || !config.statsConfig.metricsEnabled) {
return;
}
// Use the host from the config to ensure we have the latest version
const currentHost = config.host;
try {
@@ -789,7 +776,6 @@ class PollingManager {
const errorMessage =
error instanceof Error ? error.message : String(error);
// Re-check config after the async operation in case it was disabled during collection
const latestConfig = this.pollingConfigs.get(currentHost.id);
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
statsLogger.warn("Failed to collect metrics for host", {

View File

@@ -247,6 +247,7 @@ const wss = new WebSocketServer({
}
const existingConnections = userConnections.get(payload.userId);
if (existingConnections && existingConnections.size >= 3) {
return false;
}
@@ -323,7 +324,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
let isConnecting = false;
let isConnected = false;
let isCleaningUp = false;
let isShellInitializing = false; // Track shell initialization to prevent cleanup mid-setup
let isShellInitializing = false;
ws.on("close", () => {
const userWs = userConnections.get(userId);
@@ -681,10 +682,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
// Capture the connection reference immediately and verify it's still valid
const conn = sshConn;
// Additional check: verify the connection is still active before proceeding
if (!conn || isCleaningUp || !sshConn) {
sshLogger.warn(
"SSH connection was cleaned up before shell could be created",
@@ -709,12 +708,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
// Mark that we're initializing the shell to prevent cleanup
isShellInitializing = true;
isConnecting = false;
isConnected = true;
// Verify connection is still valid right before shell() call
if (!sshConn) {
sshLogger.error(
"SSH connection became null right before shell creation",
@@ -740,7 +737,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
// Shell initialization complete, clear the flag
isShellInitializing = false;
if (err) {
@@ -1265,7 +1261,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
// Don't cleanup if we're in the middle of shell initialization
if (isShellInitializing) {
sshLogger.warn(
"Cleanup attempted during shell initialization, deferring",
@@ -1274,7 +1269,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
userId,
},
);
// Retry cleanup after a short delay
setTimeout(() => cleanupSSH(timeoutId), 100);
return;
}
@@ -1327,11 +1321,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
function setupPingInterval() {
// More frequent keepalive to prevent idle disconnections
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
// Send null byte as keepalive
sshStream.write("\x00");
} catch (e: unknown) {
sshLogger.error(
@@ -1341,12 +1333,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
cleanupSSH();
}
} else if (!sshConn || !sshStream) {
// If connection or stream is lost, clear the interval
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}
}, 30000); // Reduced from 60s to 30s for more reliable keepalive
}, 30000);
}
});

View File

@@ -29,7 +29,10 @@ export function execCommand(
});
}
export function toFixedNum(n: number | null | undefined, digits = 2): number | null {
export function toFixedNum(
n: number | null | undefined,
digits = 2,
): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}

View File

@@ -36,9 +36,12 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
if (parts.length >= 10) {
const user = parts[0];
const tty = parts[1];
const ip = parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
const ip =
parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
const timeStart = parts.indexOf(parts.find(p => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "");
const timeStart = parts.indexOf(
parts.find((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "",
);
if (timeStart > 0 && parts.length > timeStart + 4) {
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
@@ -96,7 +99,9 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
failedLogins.push({
user,
ip,
time: timeStr ? new Date(timeStr).toISOString() : new Date().toISOString(),
time: timeStr
? new Date(timeStr).toISOString()
: new Date().toISOString(),
status: "failed",
});
if (ip !== "unknown") {

View File

@@ -24,10 +24,7 @@ export async function collectProcessesMetrics(client: Client): Promise<{
}> = [];
try {
const psOut = await execCommand(
client,
"ps aux --sort=-%cpu | head -n 11",
);
const psOut = await execCommand(client, "ps aux --sort=-%cpu | head -n 11");
const psLines = psOut.stdout
.split("\n")
.map((l) => l.trim())
@@ -48,10 +45,7 @@ export async function collectProcessesMetrics(client: Client): Promise<{
}
const procCount = await execCommand(client, "ps aux | wc -l");
const runningCount = await execCommand(
client,
"ps aux | grep -c ' R '",
);
const runningCount = await execCommand(client, "ps aux | grep -c ' R '");
totalProcesses = Number(procCount.stdout.trim()) - 1;
runningProcesses = Number(runningCount.stdout.trim());
} catch (e) {

View File

@@ -98,8 +98,8 @@ class AuthManager {
): Promise<boolean> {
const sessionDurationMs =
deviceType === "desktop" || deviceType === "mobile"
? 30 * 24 * 60 * 60 * 1000 // 30 days
: 7 * 24 * 60 * 60 * 1000; // 7 days
? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000;
const authenticated = await this.userCrypto.authenticateOIDCUser(
userId,
@@ -120,8 +120,8 @@ class AuthManager {
): Promise<boolean> {
const sessionDurationMs =
deviceType === "desktop" || deviceType === "mobile"
? 30 * 24 * 60 * 60 * 1000 // 30 days
: 7 * 24 * 60 * 60 * 1000; // 7 days
? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000;
const authenticated = await this.userCrypto.authenticateUser(
userId,
@@ -136,6 +136,10 @@ class AuthManager {
return authenticated;
}
async convertToOIDCEncryption(userId: string): Promise<void> {
await this.userCrypto.convertToOIDCEncryption(userId);
}
private async performLazyEncryptionMigration(userId: string): Promise<void> {
try {
const userDataKey = this.getUserDataKey(userId);

View File

@@ -9,31 +9,34 @@ class LoginRateLimiter {
private usernameAttempts = new Map<string, LoginAttempt>();
private readonly MAX_ATTEMPTS = 5;
private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes
private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
private readonly WINDOW_MS = 10 * 60 * 1000;
private readonly LOCKOUT_MS = 10 * 60 * 1000;
// Clean up old entries periodically
constructor() {
setInterval(() => this.cleanup(), 5 * 60 * 1000); // Clean every 5 minutes
setInterval(() => this.cleanup(), 5 * 60 * 1000);
}
private cleanup(): void {
const now = Date.now();
// Clean IP attempts
for (const [ip, attempt] of this.ipAttempts.entries()) {
if (attempt.lockedUntil && attempt.lockedUntil < now) {
this.ipAttempts.delete(ip);
} else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) {
} else if (
!attempt.lockedUntil &&
now - attempt.firstAttempt > this.WINDOW_MS
) {
this.ipAttempts.delete(ip);
}
}
// Clean username attempts
for (const [username, attempt] of this.usernameAttempts.entries()) {
if (attempt.lockedUntil && attempt.lockedUntil < now) {
this.usernameAttempts.delete(username);
} else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) {
} else if (
!attempt.lockedUntil &&
now - attempt.firstAttempt > this.WINDOW_MS
) {
this.usernameAttempts.delete(username);
}
}
@@ -42,15 +45,13 @@ class LoginRateLimiter {
recordFailedAttempt(ip: string, username?: string): void {
const now = Date.now();
// Record IP attempt
const ipAttempt = this.ipAttempts.get(ip);
if (!ipAttempt) {
this.ipAttempts.set(ip, {
count: 1,
firstAttempt: now,
});
} else if ((now - ipAttempt.firstAttempt) > this.WINDOW_MS) {
// Reset if outside window
} else if (now - ipAttempt.firstAttempt > this.WINDOW_MS) {
this.ipAttempts.set(ip, {
count: 1,
firstAttempt: now,
@@ -62,7 +63,6 @@ class LoginRateLimiter {
}
}
// Record username attempt if provided
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (!userAttempt) {
@@ -70,8 +70,7 @@ class LoginRateLimiter {
count: 1,
firstAttempt: now,
});
} else if ((now - userAttempt.firstAttempt) > this.WINDOW_MS) {
// Reset if outside window
} else if (now - userAttempt.firstAttempt > this.WINDOW_MS) {
this.usernameAttempts.set(username, {
count: 1,
firstAttempt: now,
@@ -92,10 +91,12 @@ class LoginRateLimiter {
}
}
isLocked(ip: string, username?: string): { locked: boolean; remainingTime?: number } {
isLocked(
ip: string,
username?: string,
): { locked: boolean; remainingTime?: number } {
const now = Date.now();
// Check IP lockout
const ipAttempt = this.ipAttempts.get(ip);
if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) {
return {
@@ -104,7 +105,6 @@ class LoginRateLimiter {
};
}
// Check username lockout
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) {
@@ -122,18 +122,19 @@ class LoginRateLimiter {
const now = Date.now();
let minRemaining = this.MAX_ATTEMPTS;
// Check IP attempts
const ipAttempt = this.ipAttempts.get(ip);
if (ipAttempt && (now - ipAttempt.firstAttempt) <= this.WINDOW_MS) {
if (ipAttempt && now - ipAttempt.firstAttempt <= this.WINDOW_MS) {
const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count);
minRemaining = Math.min(minRemaining, ipRemaining);
}
// Check username attempts
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (userAttempt && (now - userAttempt.firstAttempt) <= this.WINDOW_MS) {
const userRemaining = Math.max(0, this.MAX_ATTEMPTS - userAttempt.count);
if (userAttempt && now - userAttempt.firstAttempt <= this.WINDOW_MS) {
const userRemaining = Math.max(
0,
this.MAX_ATTEMPTS - userAttempt.count,
);
minRemaining = Math.min(minRemaining, userRemaining);
}
}
@@ -142,5 +143,4 @@ class LoginRateLimiter {
}
}
// Export singleton instance
export const loginRateLimiter = new LoginRateLimiter();

View File

@@ -344,7 +344,6 @@ class UserCrypto {
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
const existingKEKSalt = await this.getKEKSalt(userId);
// If no existing encryption, nothing to convert
if (!existingEncryptedDEK && !existingKEKSalt) {
databaseLogger.info("No existing encryption to convert for user", {
operation: "convert_to_oidc_encryption_skip",
@@ -353,11 +352,9 @@ class UserCrypto {
return;
}
// Try to get the DEK from active session
const existingDEK = this.getUserDataKey(userId);
if (existingDEK) {
// User has active session - preserve their data by re-encrypting DEK
const systemKey = this.deriveOIDCSystemKey(userId);
const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey);
systemKey.fill(0);
@@ -372,8 +369,6 @@ class UserCrypto {
},
);
} else {
// No active session - delete old encryption keys
// OIDC will create new keys on next login, but old encrypted data will be inaccessible
if (existingEncryptedDEK) {
await getDb()
.delete(settings)
@@ -389,7 +384,6 @@ class UserCrypto {
);
}
// Always remove password-based KEK salt
if (existingKEKSalt) {
await getDb()
.delete(settings)

View File

@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
@@ -8,11 +8,11 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
className,
)}
{...props}
/>
)
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -22,7 +22,7 @@ function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
);
}
export { Kbd, KbdGroup }
export { Kbd, KbdGroup };

View File

@@ -60,7 +60,7 @@ function TabsContent({
"data-[state=active]:animate-in data-[state=inactive]:animate-out",
"data-[state=inactive]:fade-out-0 data-[state=active]:fade-in-0",
"duration-150",
className
className,
)}
{...props}
/>

View File

@@ -617,7 +617,6 @@ export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
},
};
// Font families available for terminal
export const TERMINAL_FONTS = [
{
value: "Caskaydia Cove Nerd Font Mono",

View File

@@ -329,12 +329,11 @@ export interface TabContextTab {
initialTab?: string;
}
// Split Screen Layout Types
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
export interface SplitConfiguration {
layout: SplitLayout;
positions: Map<number, number>; // position index -> tab ID
positions: Map<number, number>;
}
export interface SplitLayoutOption {

View File

@@ -44,7 +44,7 @@ function AppContent() {
const now = Date.now();
if (now - lastShiftPressTime.current < 300) {
setIsCommandPaletteOpen((isOpen) => !isOpen);
lastShiftPressTime.current = 0; // Reset on double press
lastShiftPressTime.current = 0;
} else {
lastShiftPressTime.current = now;
}
@@ -63,14 +63,12 @@ function AppContent() {
useEffect(() => {
const checkAuth = () => {
setAuthLoading(true);
// Don't optimistically set isAuthenticated before checking
getUserInfo()
.then((meRes) => {
if (typeof meRes === "string" || !meRes.username) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
// Clear invalid token
localStorage.removeItem("jwt");
} else {
setIsAuthenticated(true);
@@ -83,7 +81,6 @@ function AppContent() {
setIsAdmin(false);
setUsername(null);
// Clear invalid token on any auth error
localStorage.removeItem("jwt");
const errorCode = err?.response?.data?.code;

View File

@@ -1533,7 +1533,6 @@ export function AdminSettings({
</div>
</div>
{/* Link OIDC to Password Account Dialog */}
{linkAccountAlertOpen && (
<Dialog
open={linkAccountAlertOpen}

View File

@@ -63,7 +63,6 @@ export function CredentialEditor({
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Clear error when tab changes
useEffect(() => {
setFormError(null);
}, [activeTab]);

View File

@@ -102,7 +102,7 @@ export function Dashboard({
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const rightMarginPx = 17; // Base margin when closed
const rightMarginPx = 17;
const bottomMarginPx = 8;
useEffect(() => {
@@ -213,7 +213,6 @@ export function Dashboard({
statsConfig?: string | { metricsEnabled?: boolean };
}) => {
try {
// Parse statsConfig if it's a string
let statsConfig: { metricsEnabled?: boolean } = {
metricsEnabled: true,
};
@@ -225,7 +224,6 @@ export function Dashboard({
}
}
// Skip if metrics are disabled
if (statsConfig.metricsEnabled === false) {
return null;
}

View File

@@ -1087,7 +1087,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
);
// Refresh directory to show extracted files
handleRefreshDirectory();
} catch (error: unknown) {
const err = error as { message?: string };
@@ -1132,7 +1131,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}),
);
// Refresh directory to show compressed file
handleRefreshDirectory();
clearSelection();
} catch (error: unknown) {

View File

@@ -261,7 +261,6 @@ export function FileManagerContextMenu({
});
}
// Add extract option for archive files
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
const fileName = files[0].name.toLowerCase();
const isArchive =
@@ -288,7 +287,6 @@ export function FileManagerContextMenu({
}
}
// Add compress option for selected files/folders
if (isFileContext && onCompress) {
menuItems.push({
icon: <FileArchive className="w-4 h-4" />,

View File

@@ -38,7 +38,6 @@ export function CompressDialog({
useEffect(() => {
if (open && fileNames.length > 0) {
// Generate default archive name
if (fileNames.length === 1) {
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
setArchiveName(baseName);
@@ -51,7 +50,6 @@ export function CompressDialog({
const handleCompress = () => {
if (!archiveName.trim()) return;
// Append extension if not already present
let finalName = archiveName.trim();
const extensions: Record<string, string> = {
zip: ".zip",

View File

@@ -30,7 +30,6 @@ interface PermissionsDialogProps {
onSave: (file: FileItem, permissions: string) => Promise<void>;
}
// Parse permissions like "rwxr-xr-x" or "755" to individual bits
const parsePermissions = (
perms: string,
): { owner: number; group: number; other: number } => {
@@ -38,7 +37,6 @@ const parsePermissions = (
return { owner: 0, group: 0, other: 0 };
}
// If numeric format like "755"
if (/^\d{3,4}$/.test(perms)) {
const numStr = perms.slice(-3);
return {
@@ -47,8 +45,6 @@ const parsePermissions = (
other: parseInt(numStr[2] || "0", 10),
};
}
// If symbolic format like "rwxr-xr-x" or "-rwxr-xr-x"
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
const calcBits = (str: string): number => {
@@ -66,7 +62,6 @@ const parsePermissions = (
};
};
// Convert individual bits to numeric format
const toNumeric = (owner: number, group: number, other: number): string => {
return `${owner}${group}${other}`;
};
@@ -99,7 +94,6 @@ export function PermissionsDialog({
(initialPerms.other & 1) !== 0,
);
// Reset when file changes
useEffect(() => {
if (file) {
const perms = parsePermissions(file.permissions || "644");

View File

@@ -72,7 +72,6 @@ export function HostManager({
};
const handleTabChange = (value: string) => {
// Only clear editing state when leaving the respective tabs, not when entering them
if (activeTab === "add_host" && value !== "add_host") {
setEditingHost(null);
}

View File

@@ -348,7 +348,6 @@ export function HostManagerEditor({
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
// Clear error when tab changes
useEffect(() => {
setFormError(null);
}, [activeTab]);
@@ -948,7 +947,6 @@ export function HostManagerEditor({
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
// Notify the stats server to start/update polling for this specific host
if (savedHost?.id) {
const { notifyHostCreatedOrUpdated } = await import(
"@/ui/main-axios.ts"
@@ -963,11 +961,9 @@ export function HostManagerEditor({
}
};
// Handle form validation errors
const handleFormError = () => {
const errors = form.formState.errors;
// Determine which tab contains the error
if (
errors.ip ||
errors.port ||
@@ -1088,7 +1084,6 @@ export function HostManagerEditor({
let filtered = sshConfigurations;
// Filter out the current host being edited (by ID, not by name)
if (currentHostId) {
const currentHostName = hosts.find((h) => h.id === currentHostId)?.name;
if (currentHostName) {
@@ -1097,7 +1092,6 @@ export function HostManagerEditor({
);
}
} else {
// If creating a new host, filter by the name being entered
const currentHostName =
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
filtered = sshConfigurations.filter(

View File

@@ -114,7 +114,6 @@ export function Server({
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
// Reset state when switching to a different host
setServerStatus("offline");
setMetrics(null);
setMetricsHistory([]);

View File

@@ -25,9 +25,7 @@ interface LoginStatsWidgetProps {
metricsHistory: ServerMetrics[];
}
export function LoginStatsWidget({
metrics,
}: LoginStatsWidgetProps) {
export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const { t } = useTranslation();
const loginStats = metrics?.login_stats;
@@ -52,7 +50,9 @@ export function LoginStatsWidget({
<Activity className="h-3 w-3" />
<span>{t("serverStats.totalLogins")}</span>
</div>
<div className="text-xl font-bold text-green-400">{totalLogins}</div>
<div className="text-xl font-bold text-green-400">
{totalLogins}
</div>
</div>
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
@@ -86,7 +86,9 @@ export function LoginStatsWidget({
<span className="text-green-400 font-mono truncate">
{login.user}
</span>
<span className="text-gray-500">{t("serverStats.from")}</span>
<span className="text-gray-500">
{t("serverStats.from")}
</span>
<span className="text-blue-400 font-mono truncate">
{login.ip}
</span>
@@ -118,7 +120,9 @@ export function LoginStatsWidget({
<span className="text-red-400 font-mono truncate">
{login.user}
</span>
<span className="text-gray-500">{t("serverStats.from")}</span>
<span className="text-gray-500">
{t("serverStats.from")}
</span>
<span className="text-blue-400 font-mono truncate">
{login.ip}
</span>

View File

@@ -131,13 +131,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const activityLoggedRef = useRef(false);
const keyHandlerAttachedRef = useRef(false);
// Command history tracking (Stage 1)
const { trackInput, getCurrentCommand, updateCurrentCommand } =
useCommandTracker({
hostId: hostConfig.id,
enabled: true,
onCommandExecuted: (command) => {
// Add to autocomplete history (Stage 3)
if (!autocompleteHistory.current.includes(command)) {
autocompleteHistory.current = [
command,
@@ -147,7 +145,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
},
});
// Create refs for callbacks to avoid triggering useEffect re-runs
const getCurrentCommandRef = useRef(getCurrentCommand);
const updateCurrentCommandRef = useRef(updateCurrentCommand);
@@ -156,7 +153,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
updateCurrentCommandRef.current = updateCurrentCommand;
}, [getCurrentCommand, updateCurrentCommand]);
// Real-time autocomplete (Stage 3)
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<
string[]
@@ -170,23 +166,19 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const autocompleteHistory = useRef<string[]>([]);
const currentAutocompleteCommand = useRef<string>("");
// Refs for accessing current state in event handlers
const showAutocompleteRef = useRef(false);
const autocompleteSuggestionsRef = useRef<string[]>([]);
const autocompleteSelectedIndexRef = useRef(0);
// Command history dialog (Stage 2)
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
// Create refs for context methods to avoid infinite loops
const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading);
const setCommandHistoryContextRef = useRef(
commandHistoryContext.setCommandHistory,
);
// Keep refs updated with latest context methods
useEffect(() => {
setIsLoadingRef.current = commandHistoryContext.setIsLoading;
setCommandHistoryContextRef.current =
@@ -196,7 +188,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
commandHistoryContext.setCommandHistory,
]);
// Load command history when dialog opens
useEffect(() => {
if (showHistoryDialog && hostConfig.id) {
setIsLoadingHistory(true);
@@ -219,9 +210,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}, [showHistoryDialog, hostConfig.id]);
// Load command history for autocomplete on mount (Stage 3)
useEffect(() => {
// Check if command autocomplete is enabled
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") !== "false";
@@ -240,7 +229,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}, [hostConfig.id]);
// Sync autocomplete state to refs for event handlers
useEffect(() => {
showAutocompleteRef.current = showAutocomplete;
}, [showAutocomplete]);
@@ -642,9 +630,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}),
);
terminal.onData((data) => {
// Track command input for history (Stage 1)
trackInput(data);
// Send input to server
ws.send(JSON.stringify({ type: "input", data }));
});
@@ -904,20 +890,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return "";
}
// Handle command selection from history dialog (Stage 2)
const handleSelectCommand = useCallback(
(command: string) => {
if (!terminal || !webSocketRef.current) return;
// Send the command to the terminal
// Simulate typing the command character by character
for (const char of command) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
}
// Return focus to terminal after selecting command
setTimeout(() => {
terminal.focus();
}, 100);
@@ -925,12 +907,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
[terminal],
);
// Register handlers with context
useEffect(() => {
commandHistoryContext.setOnSelectCommand(handleSelectCommand);
}, [handleSelectCommand]);
// Handle autocomplete selection (mouse click)
const handleAutocompleteSelect = useCallback(
(selectedCommand: string) => {
if (!webSocketRef.current) return;
@@ -938,22 +918,18 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const currentCmd = currentAutocompleteCommand.current;
const completion = selectedCommand.substring(currentCmd.length);
// Send completion characters to server
for (const char of completion) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
}
// Update current command tracker
updateCurrentCommand(selectedCommand);
// Close autocomplete
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
// Return focus to terminal
setTimeout(() => {
terminal?.focus();
}, 50);
@@ -963,26 +939,22 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
[terminal, updateCurrentCommand],
);
// Handle command deletion from history dialog
const handleDeleteCommand = useCallback(
async (command: string) => {
if (!hostConfig.id) return;
try {
// Call API to delete command
const { deleteCommandFromHistory } = await import(
"@/ui/main-axios.ts"
);
await deleteCommandFromHistory(hostConfig.id, command);
// Update local state
setCommandHistory((prev) => {
const newHistory = prev.filter((cmd) => cmd !== command);
setCommandHistoryContextRef.current(newHistory);
return newHistory;
});
// Update autocomplete history
autocompleteHistory.current = autocompleteHistory.current.filter(
(cmd) => cmd !== command,
);
@@ -995,7 +967,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
[hostConfig.id],
);
// Register delete handler with context
useEffect(() => {
commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
}, [handleDeleteCommand]);
@@ -1104,7 +1075,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
// Handle Ctrl+R for command history (Stage 2)
if (
e.ctrlKey &&
e.key === "r" &&
@@ -1115,7 +1085,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
e.preventDefault();
e.stopPropagation();
setShowHistoryDialog(true);
// Also trigger the sidebar to open
if (commandHistoryContext.openCommandHistory) {
commandHistoryContext.openCommandHistory();
}
@@ -1210,20 +1179,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
};
}, [xtermRef, terminal, hostConfig]);
// Register keyboard handler for autocomplete (Stage 3)
// Registered only once when terminal is created
useEffect(() => {
if (!terminal) return;
const handleCustomKey = (e: KeyboardEvent): boolean => {
// Only handle keydown events, ignore keyup to prevent double triggering
if (e.type !== "keydown") {
return true;
}
// If autocomplete is showing, handle keys specially
if (showAutocompleteRef.current) {
// Handle Escape to close autocomplete
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
@@ -1233,7 +1197,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return false;
}
// Handle Arrow keys for autocomplete navigation
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
@@ -1253,7 +1216,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return false;
}
// Handle Enter to confirm autocomplete selection
if (
e.key === "Enter" &&
autocompleteSuggestionsRef.current.length > 0
@@ -1268,7 +1230,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const currentCmd = currentAutocompleteCommand.current;
const completion = selectedCommand.substring(currentCmd.length);
// Send completion characters to server
if (webSocketRef.current?.readyState === 1) {
for (const char of completion) {
webSocketRef.current.send(
@@ -1277,10 +1238,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
}
// Update current command tracker
updateCurrentCommandRef.current(selectedCommand);
// Close autocomplete
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
@@ -1288,7 +1247,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return false;
}
// Handle Tab to cycle through suggestions
if (
e.key === "Tab" &&
!e.ctrlKey &&
@@ -1306,14 +1264,12 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return false;
}
// For any other key while autocomplete is showing, close it and let key through
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
return true;
}
// Handle Tab for autocomplete (when autocomplete is not showing)
if (
e.key === "Tab" &&
!e.ctrlKey &&
@@ -1324,12 +1280,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
e.preventDefault();
e.stopPropagation();
// Check if command autocomplete is enabled in settings
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") !== "false";
if (!autocompleteEnabled) {
// If disabled, let the terminal handle Tab normally (send to server)
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: "\t" }),
@@ -1340,7 +1294,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const currentCmd = getCurrentCommandRef.current().trim();
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
// Filter commands that start with current input
const matches = autocompleteHistory.current
.filter(
(cmd) =>
@@ -1348,10 +1301,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
cmd !== currentCmd &&
cmd.length > currentCmd.length,
)
.slice(0, 5); // Show up to 5 matches for better UX
.slice(0, 5);
if (matches.length === 1) {
// Only one match - auto-complete directly
const completedCommand = matches[0];
const completion = completedCommand.substring(currentCmd.length);
@@ -1363,12 +1315,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
updateCurrentCommandRef.current(completedCommand);
} else if (matches.length > 1) {
// Multiple matches - show selection list
currentAutocompleteCommand.current = currentCmd;
setAutocompleteSuggestions(matches);
setAutocompleteSelectedIndex(0);
// Calculate position (below or above cursor based on available space)
const cursorY = terminal.buffer.active.cursorY;
const cursorX = terminal.buffer.active.cursorX;
const rect = xtermRef.current?.getBoundingClientRect();
@@ -1379,8 +1329,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const cellWidth =
terminal.cols > 0 ? rect.width / terminal.cols : 10;
// Calculate actual menu height based on number of items
// Each item is ~32px (py-1.5), footer is ~32px, max total 240px
const itemHeight = 32;
const footerHeight = 32;
const maxMenuHeight = 240;
@@ -1388,14 +1336,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
matches.length * itemHeight + footerHeight,
maxMenuHeight,
);
// Get cursor position in viewport coordinates
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
const cursorTopY = rect.top + cursorY * cellHeight;
const spaceBelow = window.innerHeight - cursorBottomY;
const spaceAbove = cursorTopY;
// Show above cursor if not enough space below and more space above
const showAbove =
spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
@@ -1410,10 +1355,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
setShowAutocomplete(true);
}
}
return false; // Prevent default Tab behavior
return false;
}
// Let terminal handle all other keys
return true;
};
@@ -1470,7 +1414,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return;
}
// Don't set isFitted to false - keep terminal visible during resize
let rafId1: number;
let rafId2: number;

View File

@@ -19,7 +19,6 @@ export function CommandAutocomplete({
const containerRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
// Scroll selected item into view
useEffect(() => {
if (selectedRef.current && containerRef.current) {
selectedRef.current.scrollIntoView({
@@ -33,8 +32,6 @@ export function CommandAutocomplete({
return null;
}
// Calculate max height for suggestions list to ensure footer is always visible
// Footer height is approximately 32px (text + padding + border)
const footerHeight = 32;
const maxSuggestionsHeight = 240 - footerHeight;
@@ -62,9 +59,7 @@ export function CommandAutocomplete({
index === selectedIndex && "bg-gray-500/20 text-gray-400",
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {
// Optional: update selected index on hover
}}
onMouseEnter={() => {}}
>
{suggestion}
</div>

View File

@@ -105,14 +105,12 @@ export function SSHToolsSidebar({
};
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
// Update active tab when initialTab changes
useEffect(() => {
if (initialTab && isOpen) {
setActiveTab(initialTab);
}
}, [initialTab, isOpen]);
// Call onTabChange when active tab changes
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (onTabChange) {
@@ -120,14 +118,12 @@ export function SSHToolsSidebar({
}
};
// SSH Tools state
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const [rightClickCopyPaste, setRightClickCopyPaste] = useState<boolean>(
() => getCookie("rightClickCopyPaste") === "true",
);
// Snippets state
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
@@ -145,14 +141,12 @@ export function SSHToolsSidebar({
[],
);
// Command History state
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
// Split Screen state
const [splitMode, setSplitMode] = useState<"none" | "2" | "3" | "4">("none");
const [splitAssignments, setSplitAssignments] = useState<Map<number, number>>(
new Map(),
@@ -163,7 +157,6 @@ export function SSHToolsSidebar({
null,
);
// Resize state
const [isResizing, setIsResizing] = useState(false);
const startXRef = React.useRef<number | null>(null);
const startWidthRef = React.useRef<number>(sidebarWidth);
@@ -174,7 +167,6 @@ export function SSHToolsSidebar({
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
const activeTerminalHostId = activeTerminal?.hostConfig?.id;
// Get splittable tabs (terminal, server, file_manager)
const splittableTabs = tabs.filter(
(tab: TabData) =>
tab.type === "terminal" ||
@@ -183,20 +175,16 @@ export function SSHToolsSidebar({
tab.type === "user_profile",
);
// Fetch command history
useEffect(() => {
if (isOpen && activeTab === "command-history") {
if (activeTerminalHostId) {
// Save current scroll position before any state updates
const scrollTop = commandHistoryScrollRef.current?.scrollTop || 0;
getCommandHistory(activeTerminalHostId)
.then((history) => {
setCommandHistory((prevHistory) => {
const newHistory = Array.isArray(history) ? history : [];
// Only update if history actually changed
if (JSON.stringify(prevHistory) !== JSON.stringify(newHistory)) {
// Use requestAnimationFrame to restore scroll after React finishes rendering
requestAnimationFrame(() => {
if (commandHistoryScrollRef.current) {
commandHistoryScrollRef.current.scrollTop = scrollTop;
@@ -223,7 +211,6 @@ export function SSHToolsSidebar({
historyRefreshCounter,
]);
// Auto-refresh command history every 2 seconds when history tab is active
useEffect(() => {
if (isOpen && activeTab === "command-history" && activeTerminalHostId) {
const refreshInterval = setInterval(() => {
@@ -234,14 +221,12 @@ export function SSHToolsSidebar({
}
}, [isOpen, activeTab, activeTerminalHostId]);
// Filter command history based on search query
const filteredCommands = searchQuery
? commandHistory.filter((cmd) =>
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
)
: commandHistory;
// Initialize CSS variable on mount and when sidebar width changes
useEffect(() => {
document.documentElement.style.setProperty(
"--right-sidebar-width",
@@ -249,7 +234,6 @@ export function SSHToolsSidebar({
);
}, [sidebarWidth]);
// Handle window resize to adjust sidebar width
useEffect(() => {
const handleResize = () => {
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
@@ -270,7 +254,6 @@ export function SSHToolsSidebar({
}
}, [isOpen, activeTab]);
// Resize handlers
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
@@ -283,7 +266,7 @@ export function SSHToolsSidebar({
const handleMouseMove = (e: MouseEvent) => {
if (startXRef.current == null) return;
const dx = startXRef.current - e.clientX; // Reversed because we're on the right
const dx = startXRef.current - e.clientX;
const newWidth = Math.round(startWidthRef.current + dx);
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.round(window.innerWidth * 0.3);
@@ -295,13 +278,11 @@ export function SSHToolsSidebar({
finalWidth = maxWidth;
}
// Update CSS variable immediately for smooth animation
document.documentElement.style.setProperty(
"--right-sidebar-width",
`${finalWidth}px`,
);
// Update React state (this will be batched/debounced naturally)
setSidebarWidth(finalWidth);
};
@@ -323,7 +304,6 @@ export function SSHToolsSidebar({
};
}, [isResizing]);
// SSH Tools handlers
const handleTabToggle = (tabId: number) => {
setSelectedTabIds((prev) =>
prev.includes(tabId)
@@ -487,7 +467,6 @@ export function SSHToolsSidebar({
setRightClickCopyPaste(checked);
};
// Snippets handlers
const fetchSnippets = async () => {
try {
setLoading(true);
@@ -599,15 +578,12 @@ export function SSHToolsSidebar({
toast.success(t("snippets.copySuccess", { name: snippet.name }));
};
// Split Screen handlers
const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => {
setSplitMode(mode);
if (mode === "none") {
// Clear all splits
handleClearSplit();
} else {
// Clear assignments when changing modes
setSplitAssignments(new Map());
setPreviewKey((prev) => prev + 1);
}
@@ -636,7 +612,6 @@ export function SSHToolsSidebar({
setSplitAssignments((prev) => {
const newMap = new Map(prev);
// Remove this tab from any other cell
Array.from(newMap.entries()).forEach(([idx, id]) => {
if (id === draggedTabId && idx !== cellIndex) {
newMap.delete(idx);
@@ -677,7 +652,6 @@ export function SSHToolsSidebar({
const requiredSlots = parseInt(splitMode);
// Validate: All layout spots must be filled
if (splitAssignments.size < requiredSlots) {
toast.error(
t("splitScreen.error.fillAllSlots", {
@@ -688,7 +662,6 @@ export function SSHToolsSidebar({
return;
}
// Build ordered array of tab IDs based on cell index (0, 1, 2, 3)
const orderedTabIds: number[] = [];
for (let i = 0; i < requiredSlots; i++) {
const tabId = splitAssignments.get(i);
@@ -697,18 +670,15 @@ export function SSHToolsSidebar({
}
}
// First, clear ALL existing splits
const currentSplits = [...allSplitScreenTab];
currentSplits.forEach((tabId) => {
setSplitScreenTab(tabId); // Toggle off
setSplitScreenTab(tabId);
});
// Then, add only the newly assigned tabs to split IN ORDER
orderedTabIds.forEach((tabId) => {
setSplitScreenTab(tabId); // Toggle on
setSplitScreenTab(tabId);
});
// Set first assigned tab as active if current tab is not in split
if (!orderedTabIds.includes(currentTab ?? 0)) {
setCurrentTab(orderedTabIds[0]);
}
@@ -721,7 +691,6 @@ export function SSHToolsSidebar({
};
const handleClearSplit = () => {
// Remove all tabs from split screen
allSplitScreenTab.forEach((tabId) => {
setSplitScreenTab(tabId);
});
@@ -741,7 +710,6 @@ export function SSHToolsSidebar({
handleClearSplit();
};
// Command History handlers
const handleCommandSelect = (command: string) => {
if (activeTerminal?.terminalRef?.current?.sendInput) {
activeTerminal.terminalRef.current.sendInput(command);

View File

@@ -26,7 +26,6 @@ export function ElectronLoginForm({
const hasLoadedOnce = useRef(false);
useEffect(() => {
// Clear any existing token to prevent login loops with expired tokens
localStorage.removeItem("jwt");
}, []);

View File

@@ -111,7 +111,6 @@ export function AppView({
}, [updatePanelRects, fitActiveAndNotify]);
const hideThenFit = React.useCallback(() => {
// Don't hide terminals, just fit them immediately
requestAnimationFrame(() => {
updatePanelRects();
requestAnimationFrame(() => {
@@ -204,7 +203,6 @@ export function AppView({
const renderTerminalsLayer = () => {
const styles: Record<number, React.CSSProperties> = {};
// Use allSplitScreenTab order directly - it maintains the order tabs were added
const layoutTabs = allSplitScreenTab
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
.filter((t): t is TabData => t !== null && t !== undefined);
@@ -259,10 +257,8 @@ 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,
@@ -354,7 +350,6 @@ export function AppView({
};
const renderSplitOverlays = () => {
// Use allSplitScreenTab order directly - it maintains the order tabs were added
const layoutTabs = allSplitScreenTab
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
.filter((t): t is TabData => t !== null && t !== undefined);

View File

@@ -99,7 +99,6 @@ export function TopNavbar({
setCommandHistoryTabActive(true);
}, []);
// Register function to open command history sidebar
React.useEffect(() => {
commandHistory.setOpenCommandHistory(openCommandHistorySidebar);
}, [commandHistory, openCommandHistorySidebar]);
@@ -133,11 +132,9 @@ export function TopNavbar({
};
const handleTabSplit = (tabId: number) => {
// Open the sidebar to the split-screen tab
setToolsSidebarOpen(true);
setCommandHistoryTabActive(false);
setSplitScreenTabActive(true);
// Optional: could pass tabId to pre-select this tab in the sidebar
};
const handleTabClose = (tabId: number) => {

View File

@@ -35,20 +35,17 @@ export function ElectronVersionCheck({
const updateInfo = await checkElectronUpdate();
setVersionInfo(updateInfo);
// Get current app version
const currentVersion = await (window as any).electronAPI?.getAppVersion();
const dismissedVersion = localStorage.getItem(
"electron-version-check-dismissed",
);
// If this version was already dismissed, skip the modal
if (dismissedVersion === currentVersion) {
onContinue();
return;
}
if (updateInfo?.status === "up_to_date") {
// Store this version as checked (but don't show modal since up to date)
if (currentVersion) {
localStorage.setItem(
"electron-version-check-dismissed",
@@ -73,7 +70,6 @@ export function ElectronVersionCheck({
};
const handleContinue = async () => {
// Store the current version as dismissed
const currentVersion = await (window as any).electronAPI?.getAppVersion();
if (currentVersion) {
localStorage.setItem("electron-version-check-dismissed", currentVersion);

View File

@@ -144,7 +144,6 @@ export function UserProfile({
const handleFileColorCodingToggle = (enabled: boolean) => {
setFileColorCoding(enabled);
localStorage.setItem("fileColorCoding", enabled.toString());
// Trigger a re-render by dispatching a custom event
window.dispatchEvent(new Event("fileColorCodingChanged"));
};

View File

@@ -14,9 +14,6 @@ interface CommandHistoryResult {
isLoading: boolean;
}
/**
* Custom hook for managing command history and autocomplete suggestions
*/
export function useCommandHistory({
hostId,
enabled = true,
@@ -25,8 +22,7 @@ export function useCommandHistory({
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const historyCache = useRef<Map<number, string[]>>(new Map());
// Fetch command history when hostId changes
s;
useEffect(() => {
if (!enabled || !hostId) {
setCommandHistory([]);
@@ -34,14 +30,12 @@ export function useCommandHistory({
return;
}
// Check cache first
const cached = historyCache.current.get(hostId);
if (cached) {
setCommandHistory(cached);
return;
}
// Fetch from server
const fetchHistory = async () => {
setIsLoading(true);
try {
@@ -59,9 +53,6 @@ export function useCommandHistory({
fetchHistory();
}, [hostId, enabled]);
/**
* Get command suggestions based on current input
*/
const getSuggestions = useCallback(
(input: string): string[] => {
if (!input || input.trim().length === 0) {
@@ -73,7 +64,6 @@ export function useCommandHistory({
cmd.startsWith(trimmedInput),
);
// Return up to 10 suggestions, excluding exact matches
const filtered = matches
.filter((cmd) => cmd !== trimmedInput)
.slice(0, 10);
@@ -84,9 +74,6 @@ export function useCommandHistory({
[commandHistory],
);
/**
* Save a command to history
*/
const saveCommand = useCallback(
async (command: string) => {
if (!enabled || !hostId || !command || command.trim().length === 0) {
@@ -95,29 +82,24 @@ export function useCommandHistory({
const trimmedCommand = command.trim();
// Skip if it's the same as the last command
if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) {
return;
}
try {
// Save to server
await saveCommandToHistory(hostId, trimmedCommand);
// Update local state - add to beginning
setCommandHistory((prev) => {
const newHistory = [
trimmedCommand,
...prev.filter((c) => c !== trimmedCommand),
];
// Keep max 500 commands in memory
const limited = newHistory.slice(0, 500);
historyCache.current.set(hostId, limited);
return limited;
});
} catch (error) {
console.error("Failed to save command to history:", error);
// Still update local state even if server save fails
setCommandHistory((prev) => {
const newHistory = [
trimmedCommand,
@@ -130,9 +112,6 @@ export function useCommandHistory({
[enabled, hostId, commandHistory],
);
/**
* Clear current suggestions
*/
const clearSuggestions = useCallback(() => {
setSuggestions([]);
}, []);

View File

@@ -14,10 +14,6 @@ interface CommandTrackerResult {
updateCurrentCommand: (command: string) => void;
}
/**
* Hook to track terminal input and save executed commands to history
* Works with SSH terminals by monitoring input data
*/
export function useCommandTracker({
hostId,
enabled = true,
@@ -26,33 +22,25 @@ export function useCommandTracker({
const currentCommandRef = useRef<string>("");
const isInEscapeSequenceRef = useRef<boolean>(false);
/**
* Track input data and detect command execution
*/
const trackInput = useCallback(
(data: string) => {
if (!enabled || !hostId) {
return;
}
// Handle each character
for (let i = 0; i < data.length; i++) {
const char = data[i];
const charCode = char.charCodeAt(0);
// Detect escape sequences (e.g., arrow keys, function keys)
if (charCode === 27) {
// ESC
isInEscapeSequenceRef.current = true;
continue;
}
// Skip characters that are part of escape sequences
if (isInEscapeSequenceRef.current) {
// Common escape sequence endings
if (
(charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
(charCode >= 65 && charCode <= 90) ||
(charCode >= 97 && charCode <= 122) ||
charCode === 126 // ~
) {
isInEscapeSequenceRef.current = false;
@@ -60,53 +48,41 @@ export function useCommandTracker({
continue;
}
// Handle Enter key (CR or LF)
if (charCode === 13 || charCode === 10) {
// \r or \n
const command = currentCommandRef.current.trim();
// Save non-empty commands
if (command.length > 0) {
// Save to history (async, don't wait)
saveCommandToHistory(hostId, command).catch((error) => {
console.error("Failed to save command to history:", error);
});
// Callback for external handling
if (onCommandExecuted) {
onCommandExecuted(command);
}
}
// Clear current command
currentCommandRef.current = "";
continue;
}
// Handle Backspace/Delete
if (charCode === 8 || charCode === 127) {
// Backspace or DEL
if (currentCommandRef.current.length > 0) {
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
}
continue;
}
// Handle Ctrl+C, Ctrl+D, etc. - clear current command
if (charCode === 3 || charCode === 4) {
currentCommandRef.current = "";
continue;
}
// Handle Ctrl+U (clear line) - common in terminals
if (charCode === 21) {
currentCommandRef.current = "";
continue;
}
// Add printable characters to current command
if (charCode >= 32 && charCode <= 126) {
// Printable ASCII
currentCommandRef.current += char;
}
}
@@ -114,23 +90,14 @@ export function useCommandTracker({
[enabled, hostId, onCommandExecuted],
);
/**
* Get the current command being typed
*/
const getCurrentCommand = useCallback(() => {
return currentCommandRef.current;
}, []);
/**
* Clear the current command buffer
*/
const clearCurrentCommand = useCallback(() => {
currentCommandRef.current = "";
}, []);
/**
* Update the current command buffer (used for autocomplete)
*/
const updateCurrentCommand = useCallback((command: string) => {
currentCommandRef.current = command;
}, []);

View File

@@ -138,10 +138,8 @@ function getLoggerForService(serviceName: string) {
}
}
// Cache for Electron settings
const electronSettingsCache = new Map<string, string>();
// Load settings from Electron IPC on startup
if (isElectron()) {
(async () => {
try {
@@ -153,13 +151,12 @@ if (isElectron()) {
).electronAPI;
if (electronAPI?.getSetting) {
// Preload common settings
const settingsToLoad = ["rightClickCopyPaste", "jwt"];
for (const key of settingsToLoad) {
const value = await electronAPI.getSetting(key);
if (value !== null && value !== undefined) {
electronSettingsCache.set(key, value);
localStorage.setItem(key, value); // Sync to localStorage
localStorage.setItem(key, value);
}
}
}
@@ -172,13 +169,10 @@ if (isElectron()) {
export function setCookie(name: string, value: string, days = 7): void {
if (isElectron()) {
try {
// Update cache
electronSettingsCache.set(name, value);
// Set in localStorage
localStorage.setItem(name, value);
// Persist to file system via Electron IPC
const electronAPI = (
window as Window &
typeof globalThis & {
@@ -205,12 +199,10 @@ export function setCookie(name: string, value: string, days = 7): void {
export function getCookie(name: string): string | undefined {
if (isElectron()) {
try {
// Try cache first
if (electronSettingsCache.has(name)) {
return electronSettingsCache.get(name);
}
// Fallback to localStorage
const token = localStorage.getItem(name) || undefined;
if (token) {
electronSettingsCache.set(name, token);
@@ -397,22 +389,17 @@ function createApiInstance(
errorMessage === "Authentication required";
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
// Clear token from localStorage
localStorage.removeItem("jwt");
// Clear Electron settings cache
if (isElectron()) {
electronSettingsCache.delete("jwt");
}
// Clear cookie
if (typeof window !== "undefined") {
document.cookie =
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}
// Only show toast and reload for explicit session expiration
// Let the auth check in DesktopApp.tsx handle the redirect silently
if (isSessionExpired && typeof window !== "undefined") {
console.warn("Session expired - please log in again");
import("sonner").then(({ toast }) => {
@@ -1737,7 +1724,6 @@ export async function compressSSHFiles(
// FILE MANAGER DATA
// ============================================================================
// Recent Files
export async function getRecentFiles(
hostId: number,
): Promise<Record<string, unknown>> {
@@ -1916,7 +1902,6 @@ export async function refreshServerPolling(): Promise<void> {
try {
await statsApi.post("/refresh");
} catch (error) {
// Silently fail - this is a background operation
console.warn("Failed to refresh server polling:", error);
}
}
@@ -1927,7 +1912,6 @@ export async function notifyHostCreatedOrUpdated(
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);
}
}
@@ -2961,9 +2945,6 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
// COMMAND HISTORY API
// ============================================================================
/**
* Save a command to history for a specific host
*/
export async function saveCommandToHistory(
hostId: number,
command: string,
@@ -2979,12 +2960,6 @@ export async function saveCommandToHistory(
}
}
/**
* Get command history for a specific host
* Returns array of unique commands ordered by most recent
* @param hostId - The host ID to fetch history for
* @param limit - Maximum number of commands to return (default: 100)
*/
export async function getCommandHistory(
hostId: number,
limit: number = 100,
@@ -2999,9 +2974,6 @@ export async function getCommandHistory(
}
}
/**
* Delete a specific command from history
*/
export async function deleteCommandFromHistory(
hostId: number,
command: string,
@@ -3017,9 +2989,6 @@ export async function deleteCommandFromHistory(
}
}
/**
* Clear command history for a specific host (optional feature)
*/
export async function clearCommandHistory(
hostId: number,
): Promise<{ success: boolean }> {
@@ -3037,9 +3006,6 @@ export async function clearCommandHistory(
// OIDC ACCOUNT LINKING
// ============================================================================
/**
* Link an OIDC user to an existing password account (merges OIDC into password account)
*/
export async function linkOIDCToPasswordAccount(
oidcUserId: string,
targetUsername: string,