Clean up files, fix bugs in file manager, update api ports, etc.

This commit is contained in:
LukeGus
2025-09-25 01:21:15 -05:00
parent 700aa9e07d
commit 8f8ebf0c7f
49 changed files with 2497 additions and 5252 deletions

View File

@@ -1263,7 +1263,7 @@ app.use(
},
);
const HTTP_PORT = 8081;
const HTTP_PORT = 30001;
const HTTPS_PORT = process.env.SSL_PORT || 8443;
async function initializeSecurity() {

View File

@@ -148,8 +148,8 @@ async function initializeDatabaseAsync(): Promise<void> {
backupPath: migrationResult.backupPath,
});
// 🔥 CRITICAL: Migration failure with existing data
console.error("🚨 DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
// CRITICAL: Migration failure with existing data
console.error("DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
console.error("Migration error:", migrationResult.error);
console.error("Backup available at:", migrationResult.backupPath);
console.error("Manual intervention required to recover data.");
@@ -177,9 +177,9 @@ async function initializeDatabaseAsync(): Promise<void> {
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
});
// 🔥 CRITICAL: Never silently ignore database decryption failures!
// CRITICAL: Never silently ignore database decryption failures!
// This causes complete data loss for users
console.error("🚨 DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
console.error("DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
console.error("Error details:", error instanceof Error ? error.message : error);
console.error("Encrypted file exists:", DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath));
console.error("DATABASE_KEY available:", !!process.env.DATABASE_KEY);
@@ -382,11 +382,6 @@ const addColumnIfNotExists = (
.get();
} catch (e) {
try {
databaseLogger.debug(`Adding column ${column} to ${table}`, {
operation: "schema_migration",
table,
column,
});
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
databaseLogger.success(`Column ${column} added to ${table}`, {
@@ -515,22 +510,9 @@ async function saveMemoryDatabaseToFile() {
if (enableFileEncryption) {
// Save as encrypted file
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
databaseLogger.debug("In-memory database saved to encrypted file", {
operation: "memory_db_save_encrypted",
bufferSize: buffer.length,
encryptedPath: encryptedDbPath,
});
} else {
// Fallback: save as unencrypted SQLite file to prevent data loss
fs.writeFileSync(dbPath, buffer);
databaseLogger.debug("In-memory database saved to unencrypted file", {
operation: "memory_db_save_unencrypted",
bufferSize: buffer.length,
unencryptedPath: dbPath,
warning: "File encryption disabled - data saved unencrypted",
});
}
} catch (error) {
databaseLogger.error("Failed to save in-memory database", error, {
@@ -643,9 +625,6 @@ async function cleanupDatabase() {
try {
if (sqlite) {
sqlite.close();
databaseLogger.debug("Database connection closed", {
operation: "db_close",
});
}
} catch (error) {
databaseLogger.warn("Error closing database connection", {
@@ -669,9 +648,6 @@ async function cleanupDatabase() {
try {
fs.rmdirSync(tempDir);
databaseLogger.debug("Temp directory cleaned up", {
operation: "temp_dir_cleanup",
});
} catch {
// Ignore directory removal errors
}
@@ -745,12 +721,6 @@ function getMemoryDatabaseBuffer(): Buffer {
try {
// Export in-memory database to buffer
const buffer = memoryDatabase.serialize();
databaseLogger.debug("Memory database serialized to buffer", {
operation: "memory_db_serialize",
bufferSize: buffer.length,
});
return buffer;
} catch (error) {
databaseLogger.error(

View File

@@ -28,8 +28,6 @@ function generateSSHKeyPair(
publicKey?: string;
error?: string;
} {
console.log("Generating SSH key pair with ssh2:", keyType);
try {
// Convert our keyType to ssh2 format
let ssh2Type = keyType;
@@ -54,17 +52,12 @@ function generateSSHKeyPair(
// Use ssh2's native key generation
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
console.log("SSH key pair generated successfully!");
console.log("Private key length:", keyPair.private.length);
console.log("Public key preview:", keyPair.public.substring(0, 50) + "...");
return {
success: true,
privateKey: keyPair.private,
publicKey: keyPair.public,
};
} catch (error) {
console.error("SSH key generation failed:", error);
return {
success: false,
error:
@@ -785,20 +778,12 @@ router.post(
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
console.log("=== Key Detection API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Private key type:", typeof privateKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
try {
console.log("Calling parseSSHKey...");
const keyInfo = parseSSHKey(privateKey, keyPassword);
console.log("parseSSHKey result:", keyInfo);
const response = {
success: keyInfo.success,
@@ -808,10 +793,8 @@ router.post(
error: keyInfo.error || null,
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in detect-key-type endpoint:", error);
authLogger.error("Failed to detect key type", error);
res.status(500).json({
error:
@@ -829,20 +812,12 @@ router.post(
async (req: Request, res: Response) => {
const { publicKey } = req.body;
console.log("=== Public Key Detection API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Public key provided:", !!publicKey);
console.log("Public key type:", typeof publicKey);
if (!publicKey || typeof publicKey !== "string") {
console.log("Invalid public key provided");
return res.status(400).json({ error: "Public key is required" });
}
try {
console.log("Calling parsePublicKey...");
const keyInfo = parsePublicKey(publicKey);
console.log("parsePublicKey result:", keyInfo);
const response = {
success: keyInfo.success,
@@ -851,10 +826,8 @@ router.post(
error: keyInfo.error || null,
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in detect-public-key-type endpoint:", error);
authLogger.error("Failed to detect public key type", error);
res.status(500).json({
error:
@@ -874,29 +847,20 @@ router.post(
async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body;
console.log("=== Key Pair Validation API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Public key provided:", !!publicKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
if (!publicKey || typeof publicKey !== "string") {
console.log("Invalid public key provided");
return res.status(400).json({ error: "Public key is required" });
}
try {
console.log("Calling validateKeyPair...");
const validationResult = validateKeyPair(
privateKey,
publicKey,
keyPassword,
);
console.log("validateKeyPair result:", validationResult);
const response = {
isValid: validationResult.isValid,
@@ -906,10 +870,8 @@ router.post(
error: validationResult.error || null,
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in validate-key-pair endpoint:", error);
authLogger.error("Failed to validate key pair", error);
res.status(500).json({
error:
@@ -929,11 +891,6 @@ router.post(
async (req: Request, res: Response) => {
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
console.log("=== Generate Key Pair API Called ===");
console.log("Key type:", keyType);
console.log("Key size:", keySize);
console.log("Has passphrase:", !!passphrase);
try {
// Generate SSH keys directly with ssh2
const result = generateSSHKeyPair(keyType, keySize, passphrase);
@@ -950,17 +907,14 @@ router.post(
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
};
console.log("SSH key pair generated successfully:", keyType);
res.json(response);
} else {
console.error("SSH key generation failed:", result.error);
res.status(500).json({
success: false,
error: result.error || "Failed to generate SSH key pair",
});
}
} catch (error) {
console.error("Exception in generate-key-pair endpoint:", error);
authLogger.error("Failed to generate key pair", error);
res.status(500).json({
success: false,
@@ -981,23 +935,11 @@ router.post(
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
console.log("=== Generate Public Key API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Private key type:", typeof privateKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
try {
console.log(
"Using Node.js crypto to generate public key from private key...",
);
console.log("Private key length:", privateKey.length);
console.log("Private key first 100 chars:", privateKey.substring(0, 100));
// First try to create private key object from the input
let privateKeyObj;
let parseAttempts = [];
@@ -1008,7 +950,6 @@ router.post(
key: privateKey,
passphrase: keyPassword,
});
console.log("Successfully parsed with passphrase method");
} catch (error) {
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
}
@@ -1017,7 +958,6 @@ router.post(
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey(privateKey);
console.log("Successfully parsed without passphrase");
} catch (error) {
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
}
@@ -1031,7 +971,6 @@ router.post(
format: "pem",
type: "pkcs8",
});
console.log("Successfully parsed as PKCS#8");
} catch (error) {
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
}
@@ -1048,7 +987,6 @@ router.post(
format: "pem",
type: "pkcs1",
});
console.log("Successfully parsed as PKCS#1 RSA");
} catch (error) {
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
}
@@ -1065,7 +1003,6 @@ router.post(
format: "pem",
type: "sec1",
});
console.log("Successfully parsed as SEC1 EC");
} catch (error) {
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
}
@@ -1073,23 +1010,11 @@ router.post(
// Final attempt: Try using ssh2 as fallback
if (!privateKeyObj) {
console.log("Attempting fallback to parseSSHKey function...");
try {
const keyInfo = parseSSHKey(privateKey, keyPassword);
console.log("parseSSHKey fallback result:", keyInfo);
if (keyInfo.success && keyInfo.publicKey) {
// Ensure SSH2 fallback also returns proper string
const publicKeyString = String(keyInfo.publicKey);
console.log(
"SSH2 fallback public key type:",
typeof publicKeyString,
);
console.log(
"SSH2 fallback public key length:",
publicKeyString.length,
);
return res.json({
success: true,
publicKey: publicKeyString,
@@ -1106,7 +1031,6 @@ router.post(
}
if (!privateKeyObj) {
console.error("All parsing attempts failed:", parseAttempts);
return res.status(400).json({
success: false,
error: "Unable to parse private key. Tried multiple formats.",
@@ -1121,30 +1045,12 @@ router.post(
format: "pem",
});
// Debug: Check what we're actually generating
console.log("Generated public key type:", typeof publicKeyPem);
console.log(
"Generated public key is Buffer:",
Buffer.isBuffer(publicKeyPem),
);
// Ensure publicKeyPem is a string
const publicKeyString =
typeof publicKeyPem === "string"
? publicKeyPem
: publicKeyPem.toString("utf8");
console.log("Public key string length:", publicKeyString.length);
console.log(
"Generated public key first 100 chars:",
publicKeyString.substring(0, 100),
);
console.log("Public key is string:", typeof publicKeyString === "string");
console.log(
"Public key contains PEM header:",
publicKeyString.includes("-----BEGIN PUBLIC KEY-----"),
);
// Detect key type from the private key object
let keyType = "unknown";
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
@@ -1169,12 +1075,9 @@ router.post(
const base64Data = publicKeyBuffer.toString("base64");
finalPublicKey = `${keyType} ${base64Data}`;
formatType = "ssh";
console.log("SSH format public key generated!");
} else {
console.warn("ssh2 parsing failed, using PEM format");
}
} catch (sshError) {
console.warn("ssh2 failed, using PEM format");
// Use PEM format as fallback
}
const response = {
@@ -1184,20 +1087,8 @@ router.post(
format: formatType,
};
console.log("Final response publicKey type:", typeof response.publicKey);
console.log("Final response publicKey format:", response.format);
console.log(
"Final response publicKey length:",
response.publicKey.length,
);
console.log(
"Public key generated successfully using crypto module:",
keyType,
);
res.json(response);
} catch (error) {
console.error("Exception in generate-public-key endpoint:", error);
authLogger.error("Failed to generate public key", error);
res.status(500).json({
success: false,

View File

@@ -74,21 +74,6 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
)
);
console.log("=== AUTOSTART QUERY DEBUG ===");
console.log("Found autostart hosts count:", autostartHosts.length);
autostartHosts.forEach((host, index) => {
console.log(`Host ${index + 1}:`, {
id: host.id,
ip: host.ip,
username: host.username,
hasAutostartPassword: !!host.autostartPassword,
hasAutostartKey: !!host.autostartKey,
autostartPasswordLength: host.autostartPassword?.length || 0,
autostartKeyLength: host.autostartKey?.length || 0
});
});
console.log("=== END AUTOSTART QUERY DEBUG ===");
sshLogger.info("Internal autostart endpoint accessed", {
operation: "autostart_internal_access",
configCount: autostartHosts.length,
@@ -102,20 +87,6 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
? JSON.parse(host.tunnelConnections)
: [];
// Debug: Log what we're reading from database
sshLogger.info(`Autostart host from DB:`, {
hostId: host.id,
ip: host.ip,
username: host.username,
hasAutostartPassword: !!host.autostartPassword,
hasAutostartKey: !!host.autostartKey,
hasEncryptedPassword: !!host.password,
hasEncryptedKey: !!host.key,
authType: host.authType,
autostartPasswordLength: host.autostartPassword?.length || 0,
autostartKeyLength: host.autostartKey?.length || 0,
});
return {
id: host.id,
userId: host.userId,
@@ -179,22 +150,6 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
? JSON.parse(host.tunnelConnections)
: [];
// Debug: Log what we're reading from database for all hosts
sshLogger.info(`All hosts endpoint - host from DB:`, {
hostId: host.id,
ip: host.ip,
username: host.username,
hasAutostartPassword: !!host.autostartPassword,
hasAutostartKey: !!host.autostartKey,
hasEncryptedPassword: !!host.password,
hasEncryptedKey: !!host.key,
authType: host.authType,
autostartPasswordLength: host.autostartPassword?.length || 0,
autostartKeyLength: host.autostartKey?.length || 0,
encryptedPasswordLength: host.password?.length || 0,
encryptedKeyLength: host.key?.length || 0,
});
return {
id: host.id,
userId: host.userId,
@@ -1474,17 +1429,6 @@ router.post(
// Decrypt sensitive fields
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
// Debug: Log what we're about to save
console.log("=== AUTOSTART DEBUG: Decrypted credentials ===");
console.log("sshConfigId:", sshConfigId);
console.log("authType:", config.authType);
console.log("hasPassword:", !!decryptedConfig.password);
console.log("hasKey:", !!decryptedConfig.key);
console.log("hasKeyPassword:", !!decryptedConfig.keyPassword);
console.log("passwordLength:", decryptedConfig.password?.length || 0);
console.log("keyLength:", decryptedConfig.key?.length || 0);
console.log("=== END AUTOSTART DEBUG ===");
// Also handle tunnel connections - populate endpoint credentials
let updatedTunnelConnections = config.tunnelConnections;
if (config.tunnelConnections) {
@@ -1495,9 +1439,6 @@ router.post(
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: any) => {
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
console.log("=== RESOLVING ENDPOINT CREDENTIALS ===");
console.log("endpointHost:", tunnel.endpointHost);
// Find endpoint host by name or username@ip
const endpointHosts = await db.select()
.from(sshData)
@@ -1509,17 +1450,9 @@ router.post(
);
if (endpointHost) {
console.log("Found endpoint host:", endpointHost.id, endpointHost.ip);
// Decrypt endpoint host credentials
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
console.log("Endpoint credentials:", {
hasPassword: !!decryptedEndpoint.password,
hasKey: !!decryptedEndpoint.key,
passwordLength: decryptedEndpoint.password?.length || 0
});
// Add endpoint credentials to tunnel connection
return {
...tunnel,
@@ -1535,9 +1468,11 @@ router.post(
);
updatedTunnelConnections = JSON.stringify(resolvedConnections);
console.log("=== UPDATED TUNNEL CONNECTIONS ===");
} catch (error) {
console.log("=== TUNNEL CONNECTION UPDATE FAILED ===", error);
sshLogger.warn("Failed to update tunnel connections", {
operation: "tunnel_connections_update_failed",
error: error instanceof Error ? error.message : "Unknown error"
});
}
}
@@ -1551,36 +1486,14 @@ router.post(
})
.where(eq(sshData.id, sshConfigId));
// Debug: Log update result
console.log("=== AUTOSTART DEBUG: Update result ===");
console.log("updateResult:", updateResult);
console.log("update completed for sshConfigId:", sshConfigId);
console.log("=== END UPDATE DEBUG ===");
// Force database save after autostart update
try {
await DatabaseSaveTrigger.triggerSave();
console.log("=== DATABASE SAVE TRIGGERED AFTER AUTOSTART ===");
} catch (saveError) {
console.log("=== DATABASE SAVE FAILED ===", saveError);
}
// Verify the data was actually saved
try {
const verifyQuery = await db.select()
.from(sshData)
.where(eq(sshData.id, sshConfigId));
if (verifyQuery.length > 0) {
const saved = verifyQuery[0];
console.log("=== VERIFICATION: Data actually saved ===");
console.log("autostartPassword exists:", !!saved.autostartPassword);
console.log("autostartKey exists:", !!saved.autostartKey);
console.log("autostartPassword length:", saved.autostartPassword?.length || 0);
console.log("=== END VERIFICATION ===");
}
} catch (verifyError) {
console.log("=== VERIFICATION FAILED ===", verifyError);
sshLogger.warn("Database save failed after autostart", {
operation: "autostart_db_save_failed",
error: saveError instanceof Error ? saveError.message : "Unknown error"
});
}
sshLogger.success("AutoStart enabled successfully", {

View File

@@ -505,7 +505,7 @@ router.get("/oidc/authorize", async (req, res) => {
"http://localhost:5173";
if (origin.includes("localhost")) {
origin = "http://localhost:8081";
origin = "http://localhost:30001";
}
const redirectUri = `${origin}/users/oidc/callback`;

View File

@@ -95,7 +95,6 @@ function scheduleSessionCleanup(sessionId: string) {
// Increase timeout to 30 minutes of inactivity
session.timeout = setTimeout(() => {
fileLogger.info(`Cleaning up inactive SSH session: ${sessionId}`);
cleanupSession(sessionId);
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
}
@@ -342,12 +341,6 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
fileLogger.debug(`SSH session keepalive: ${sessionId}`, {
operation: "ssh_keepalive",
sessionId,
lastActive: session.lastActive,
});
res.json({
status: "success",
connected: true,
@@ -2124,7 +2117,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
});
});
const PORT = 8084;
const PORT = 30004;
app.listen(PORT, async () => {
fileLogger.success("File Manager API server started", {
operation: "server_start",

View File

@@ -412,19 +412,6 @@ async function resolveHostCredentials(
if (credentials.length > 0) {
const credential = credentials[0];
statsLogger.debug(
`Using credential ${credential.id} for host ${host.id}`,
{
operation: "credential_resolve",
credentialId: credential.id,
authType: credential.authType,
hasPassword: !!credential.password,
hasKey: !!credential.key,
passwordLength: credential.password?.length || 0,
keyLength: credential.key?.length || 0,
},
);
baseHost.credentialId = credential.id;
baseHost.username = credential.username;
baseHost.authType = credential.authType;
@@ -471,20 +458,6 @@ function addLegacyCredentials(baseHost: any, host: any): void {
}
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
statsLogger.debug(`Building SSH config for host ${host.ip}`, {
operation: "ssh_config",
authType: host.authType,
hasPassword: !!host.password,
hasKey: !!host.key,
username: host.username,
passwordLength: host.password?.length || 0,
keyLength: host.key?.length || 0,
passwordType: typeof host.password,
passwordRaw: host.password
? JSON.stringify(host.password.substring(0, 20))
: null,
});
const base: ConnectConfig = {
host: host.ip,
port: host.port || 22,
@@ -521,26 +494,12 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
if (!host.password) {
throw new Error(`No password available for host ${host.ip}`);
}
statsLogger.debug(`Using password auth for ${host.ip}`, {
operation: "ssh_config",
passwordLength: host.password.length,
passwordFirst3: host.password.substring(0, 3),
passwordLast3: host.password.substring(host.password.length - 3),
passwordType: typeof host.password,
passwordIsString: typeof host.password === "string",
});
(base as any).password = host.password;
} else if (host.authType === "key") {
if (!host.key) {
throw new Error(`No SSH key available for host ${host.ip}`);
}
statsLogger.debug(`Using key auth for ${host.ip}`, {
operation: "ssh_config",
keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + "...",
hasPassphrase: !!host.keyPassword,
});
try {
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
throw new Error("Invalid private key format");
@@ -988,7 +947,7 @@ process.on("SIGTERM", () => {
process.exit(0);
});
const PORT = 8085;
const PORT = 30005;
app.listen(PORT, async () => {
statsLogger.success("Server Stats API server started", {
operation: "server_start",

View File

@@ -17,7 +17,7 @@ const userCrypto = UserCrypto.getInstance();
const userConnections = new Map<string, Set<WebSocket>>();
const wss = new WebSocketServer({
port: 8082,
port: 30002,
// WebSocket authentication during handshake
verifyClient: async (info) => {
try {
@@ -90,7 +90,7 @@ const wss = new WebSocketServer({
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
operation: "server_start",
port: 8082,
port: 30002,
features: ["JWT_auth", "connection_limits", "data_access_control"]
});
@@ -369,26 +369,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
}, 60000);
sshLogger.debug(`Terminal SSH setup`, {
operation: "terminal_ssh",
hostId: id,
ip,
authType,
hasPassword: !!password,
passwordLength: password?.length || 0,
hasCredentialId: !!credentialId,
});
// SECURITY: Never log password information - removed password preview logging
sshLogger.debug(`SSH authentication setup`, {
operation: "terminal_ssh_auth_setup",
userId,
hostId: id,
authType,
hasPassword: !!password,
hasCredentialId: !!credentialId,
});
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
if (credentialId && id && hostConfig.userId) {
try {
@@ -502,12 +482,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
// Change to initial path if specified
if (initialPath && initialPath.trim() !== "") {
sshLogger.debug(`Changing to initial path: ${initialPath}`, {
operation: "ssh_initial_path",
hostId: id,
path: initialPath,
});
// Send cd command to change directory
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
stream.write(cdCommand);
@@ -515,12 +489,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
// Execute command if specified
if (executeCommand && executeCommand.trim() !== "") {
sshLogger.debug(`Executing command: ${executeCommand}`, {
operation: "ssh_execute_command",
hostId: id,
command: executeCommand,
});
// Wait a moment for the cd command to complete, then execute the command
setTimeout(() => {
const command = `${executeCommand}\n`;

View File

@@ -1283,7 +1283,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
// Get autostart hosts for tunnel configs
const autostartResponse = await axios.get(
"http://localhost:8081/ssh/db/host/internal",
"http://localhost:30001/ssh/db/host/internal",
{
headers: {
"Content-Type": "application/json",
@@ -1294,7 +1294,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
// Get all hosts for endpointHost resolution
const allHostsResponse = await axios.get(
"http://localhost:8081/ssh/db/host/internal/all",
"http://localhost:30001/ssh/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
@@ -1420,7 +1420,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
}
}
const PORT = 8083;
const PORT = 30003;
app.listen(PORT, () => {
tunnelLogger.success("SSH Tunnel API server started", {
operation: "server_start",

View File

@@ -121,10 +121,6 @@ class AuthManager {
migratedFieldsCount: migrationResult.migratedFieldsCount,
});
} else {
databaseLogger.debug("No lazy encryption migration needed for user", {
operation: "lazy_encryption_migration_not_needed",
userId,
});
}
} catch (error) {

View File

@@ -24,13 +24,13 @@ export class AutoSSLSetup {
*/
static async initialize(): Promise<void> {
try {
systemLogger.info("🔐 Initializing SSL/TLS configuration...", {
systemLogger.info("Initializing SSL/TLS configuration...", {
operation: "ssl_auto_init"
});
// Check if SSL is already properly configured
if (await this.isSSLConfigured()) {
systemLogger.info("SSL configuration already exists and is valid", {
systemLogger.info("SSL configuration already exists and is valid", {
operation: "ssl_already_configured"
});
return;
@@ -42,19 +42,19 @@ export class AutoSSLSetup {
// Setup environment variables for SSL
await this.setupEnvironmentVariables();
systemLogger.success("🚀 SSL/TLS configuration completed successfully", {
systemLogger.success("SSL/TLS configuration completed successfully", {
operation: "ssl_auto_init_complete",
https_port: process.env.SSL_PORT || "8443",
note: "HTTPS/WSS is now enabled by default"
});
} catch (error) {
systemLogger.error("Failed to initialize SSL configuration", error, {
systemLogger.error("Failed to initialize SSL configuration", error, {
operation: "ssl_auto_init_failed"
});
// Don't crash the application - fallback to HTTP
systemLogger.warn("⚠️ Falling back to HTTP-only mode", {
systemLogger.warn("Falling back to HTTP-only mode", {
operation: "ssl_fallback_http"
});
}
@@ -84,7 +84,7 @@ export class AutoSSLSetup {
* Generate SSL certificates automatically
*/
private static async generateSSLCertificates(): Promise<void> {
systemLogger.info("🔑 Generating SSL certificates for local development...", {
systemLogger.info("Generating SSL certificates for local development...", {
operation: "ssl_cert_generation"
});
@@ -142,7 +142,7 @@ IP.2 = ::1
// Clean up temp config
await fs.unlink(configFile);
systemLogger.success("SSL certificates generated successfully", {
systemLogger.success("SSL certificates generated successfully", {
operation: "ssl_cert_generated",
cert_path: this.CERT_FILE,
key_path: this.KEY_FILE,
@@ -158,7 +158,7 @@ IP.2 = ::1
* Setup environment variables for SSL configuration
*/
private static async setupEnvironmentVariables(): Promise<void> {
systemLogger.info("⚙️ Configuring SSL environment variables...", {
systemLogger.info("Configuring SSL environment variables...", {
operation: "ssl_env_setup"
});
@@ -207,7 +207,7 @@ IP.2 = ::1
if (hasChanges || !envContent) {
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + '\n');
systemLogger.info("SSL environment variables configured", {
systemLogger.info("SSL environment variables configured", {
operation: "ssl_env_configured",
file: this.ENV_FILE,
variables: Object.keys(sslEnvVars)
@@ -248,12 +248,12 @@ IP.2 = ::1
║ HTTP Port: ${(process.env.PORT || "8080").padEnd(47)}
║ Domain: ${config.domain.padEnd(47)}
║ ║
🌐 Access URLs: ║
║ Access URLs: ║
║ • HTTPS: https://localhost:${config.port.toString().padEnd(31)}
║ • HTTP: http://localhost:${(process.env.PORT || "8080").padEnd(32)}
║ ║
🔐 WebSocket connections automatically use WSS over HTTPS ║
⚠️ Self-signed certificate will show browser warnings ║
║ WebSocket connections automatically use WSS over HTTPS ║
║ Self-signed certificate will show browser warnings ║
╚══════════════════════════════════════════════════════════════╝
`);
}

View File

@@ -440,10 +440,6 @@ class DatabaseFileEncryption {
for (const tempFile of tempFiles) {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
databaseLogger.debug("Cleaned up temporary file", {
operation: "temp_cleanup",
file: tempFile,
});
}
}
} catch (error) {

View File

@@ -183,12 +183,6 @@ export class DatabaseMigration {
});
return false;
}
databaseLogger.debug("Table verification passed", {
operation: "migration_verify_table_success",
table: table.name,
rows: originalCount.count,
});
}
databaseLogger.success("Migration integrity verification completed", {
@@ -253,11 +247,6 @@ export class DatabaseMigration {
for (const table of tables) {
memoryDb.exec(table.sql);
migratedTables++;
databaseLogger.debug("Table structure created", {
operation: "migration_table_created",
table: table.name,
});
}
// 6. 禁用外键约束以避免插入顺序问题
@@ -287,12 +276,6 @@ export class DatabaseMigration {
insertTransaction(rows);
migratedRows += rows.length;
databaseLogger.debug("Table data migrated", {
operation: "migration_table_data",
table: table.name,
rows: rows.length,
});
}
}
@@ -424,10 +407,6 @@ export class DatabaseMigration {
for (const file of [...backupsToDelete, ...migratedToDelete]) {
try {
fs.unlinkSync(file.path);
databaseLogger.debug("Cleaned up old migration file", {
operation: "migration_cleanup",
file: file.name,
});
} catch (error) {
databaseLogger.warn("Failed to cleanup old migration file", {
operation: "migration_cleanup_failed",

View File

@@ -42,27 +42,13 @@ export class DatabaseSaveTrigger {
// 防抖延迟2秒执行如果2秒内有新的保存请求则重新计时
this.saveTimeout = setTimeout(async () => {
if (this.pendingSave) {
databaseLogger.debug("Database save already in progress, skipping", {
operation: "db_save_trigger_skip",
reason,
});
return;
}
this.pendingSave = true;
try {
databaseLogger.debug("Triggering database save", {
operation: "db_save_trigger_start",
reason,
});
await this.saveFunction!();
databaseLogger.debug("Database save completed", {
operation: "db_save_trigger_success",
reason,
});
} catch (error) {
databaseLogger.error("Database save failed", error, {
operation: "db_save_trigger_failed",
@@ -94,10 +80,6 @@ export class DatabaseSaveTrigger {
}
if (this.pendingSave) {
databaseLogger.debug("Database save already in progress, waiting", {
operation: "db_save_trigger_force_wait",
reason,
});
return;
}

View File

@@ -41,22 +41,11 @@ export class LazyFieldEncryption {
if (this.isPlaintextField(fieldValue)) {
// 明文数据,直接返回
databaseLogger.debug("Field detected as plaintext, returning as-is", {
operation: "lazy_encryption_plaintext_detected",
recordId,
fieldName,
valuePreview: fieldValue.substring(0, 10) + "...",
});
return fieldValue;
} else {
// 加密数据,需要解密
try {
const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
databaseLogger.debug("Field decrypted successfully", {
operation: "lazy_encryption_decrypt_success",
recordId,
fieldName,
});
return decrypted;
} catch (error) {
databaseLogger.error("Failed to decrypt field", error, {
@@ -108,11 +97,6 @@ export class LazyFieldEncryption {
}
} else {
// 已经加密,无需处理
databaseLogger.debug("Field already encrypted, no migration needed", {
operation: "lazy_encryption_already_encrypted",
recordId,
fieldName,
});
return { encrypted: fieldValue, wasPlaintext: false };
}
}
@@ -149,12 +133,6 @@ export class LazyFieldEncryption {
updatedRecord[fieldName] = encrypted;
migratedFields.push(fieldName);
needsUpdate = true;
databaseLogger.debug("Record field migrated to encrypted", {
operation: "lazy_encryption_record_field_migrated",
recordId,
fieldName,
});
} catch (error) {
databaseLogger.error("Failed to migrate record field", error, {
operation: "lazy_encryption_record_field_failed",

View File

@@ -14,10 +14,23 @@ export interface LogContext {
[key: string]: any;
}
// Sensitive fields that should be masked in logs
const SENSITIVE_FIELDS = [
'password', 'passphrase', 'key', 'privateKey', 'publicKey', 'token', 'secret',
'clientSecret', 'keyPassword', 'autostartPassword', 'autostartKey', 'autostartKeyPassword',
'credentialId', 'authToken', 'jwt', 'session', 'cookie'
];
// Fields that should be truncated if too long
const TRUNCATE_FIELDS = ['data', 'content', 'body', 'response', 'request'];
class Logger {
private serviceName: string;
private serviceIcon: string;
private serviceColor: string;
private logCounts = new Map<string, { count: number; lastLog: number }>();
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute
private readonly RATE_LIMIT_MAX = 10; // Max logs per minute for same message
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
this.serviceName = serviceName;
@@ -29,6 +42,32 @@ class Logger {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
}
private sanitizeContext(context: LogContext): LogContext {
const sanitized = { ...context };
// Mask sensitive fields
for (const field of SENSITIVE_FIELDS) {
if (sanitized[field] !== undefined) {
if (typeof sanitized[field] === 'string' && sanitized[field].length > 0) {
sanitized[field] = '[MASKED]';
} else if (typeof sanitized[field] === 'boolean') {
sanitized[field] = sanitized[field] ? '[PRESENT]' : '[ABSENT]';
} else {
sanitized[field] = '[MASKED]';
}
}
}
// Truncate long fields
for (const field of TRUNCATE_FIELDS) {
if (sanitized[field] && typeof sanitized[field] === 'string' && sanitized[field].length > 100) {
sanitized[field] = sanitized[field].substring(0, 100) + '...';
}
}
return sanitized;
}
private formatMessage(
level: LogLevel,
message: string,
@@ -41,14 +80,15 @@ class Logger {
let contextStr = "";
if (context) {
const sanitizedContext = this.sanitizeContext(context);
const contextParts = [];
if (context.operation) contextParts.push(`op:${context.operation}`);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.requestId) contextParts.push(`req:${context.requestId}`);
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
if (sanitizedContext.operation) contextParts.push(`op:${sanitizedContext.operation}`);
if (sanitizedContext.userId) contextParts.push(`user:${sanitizedContext.userId}`);
if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`);
if (sanitizedContext.tunnelName) contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
if (sanitizedContext.sessionId) contextParts.push(`session:${sanitizedContext.sessionId}`);
if (sanitizedContext.requestId) contextParts.push(`req:${sanitizedContext.requestId}`);
if (sanitizedContext.duration) contextParts.push(`duration:${sanitizedContext.duration}ms`);
if (contextParts.length > 0) {
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
@@ -75,30 +115,51 @@ class Logger {
}
}
private shouldLog(level: LogLevel): boolean {
private shouldLog(level: LogLevel, message: string): boolean {
if (level === "debug" && process.env.NODE_ENV === "production") {
return false;
}
// Rate limiting for frequent messages
const now = Date.now();
const logKey = `${level}:${message}`;
const logInfo = this.logCounts.get(logKey);
if (logInfo) {
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
logInfo.count++;
if (logInfo.count > this.RATE_LIMIT_MAX) {
return false; // Rate limited
}
} else {
// Reset counter for new window
logInfo.count = 1;
logInfo.lastLog = now;
}
} else {
this.logCounts.set(logKey, { count: 1, lastLog: now });
}
return true;
}
debug(message: string, context?: LogContext): void {
if (!this.shouldLog("debug")) return;
if (!this.shouldLog("debug", message)) return;
console.debug(this.formatMessage("debug", message, context));
}
info(message: string, context?: LogContext): void {
if (!this.shouldLog("info")) return;
if (!this.shouldLog("info", message)) return;
console.log(this.formatMessage("info", message, context));
}
warn(message: string, context?: LogContext): void {
if (!this.shouldLog("warn")) return;
if (!this.shouldLog("warn", message)) return;
console.warn(this.formatMessage("warn", message, context));
}
error(message: string, error?: unknown, context?: LogContext): void {
if (!this.shouldLog("error")) return;
if (!this.shouldLog("error", message)) return;
console.error(this.formatMessage("error", message, context));
if (error) {
console.error(error);
@@ -106,7 +167,7 @@ class Logger {
}
success(message: string, context?: LogContext): void {
if (!this.shouldLog("success")) return;
if (!this.shouldLog("success", message)) return;
console.log(this.formatMessage("success", message, context));
}

View File

@@ -53,13 +53,6 @@ class SimpleDBOps {
userDataKey
);
databaseLogger.debug(`Inserted encrypted record into ${tableName}`, {
operation: "simple_insert",
table: tableName,
userId,
recordId: result[0].id,
});
return decryptedResult as T;
}
@@ -111,13 +104,6 @@ class SimpleDBOps {
userDataKey
);
databaseLogger.debug(`Selected single record from ${tableName}`, {
operation: "simple_select_one",
table: tableName,
userId,
recordId: result.id,
});
return decryptedResult;
}
@@ -155,13 +141,6 @@ class SimpleDBOps {
userDataKey
);
databaseLogger.debug(`Updated records in ${tableName}`, {
operation: "simple_update",
table: tableName,
userId,
updatedCount: result.length,
});
return decryptedResults as T[];
}

View File

@@ -57,7 +57,6 @@ function detectKeyTypeFromContent(keyContent: string): string {
// Default to RSA for OpenSSH format if we can't detect specifically
return "ssh-rsa";
} catch (error) {
console.warn("Failed to decode OpenSSH key content:", error);
// If decoding fails, default to RSA as it's most common for OpenSSH format
return "ssh-rsa";
}
@@ -103,7 +102,6 @@ function detectKeyTypeFromContent(keyContent: string): string {
}
} catch (error) {
// If decoding fails, fall back to length-based detection
console.warn("Failed to decode private key for type detection:", error);
}
// Fallback: Try to detect key type from the content structure
@@ -176,7 +174,6 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
}
} catch (error) {
// If decoding fails, fall back to length-based detection
console.warn("Failed to decode public key for type detection:", error);
}
// Fallback: Try to guess based on key length
@@ -246,15 +243,6 @@ export function parseSSHKey(
privateKeyData: string,
passphrase?: string,
): KeyInfo {
console.log("=== SSH Key Parsing Debug ===");
console.log("Key length:", privateKeyData?.length || "undefined");
console.log(
"First 100 chars:",
privateKeyData?.substring(0, 100) || "undefined",
);
console.log("ssh2Utils available:", typeof ssh2Utils);
console.log("parseKey function available:", typeof ssh2Utils?.parseKey);
try {
let keyType = "unknown";
let publicKey = "";
@@ -263,30 +251,17 @@ export function parseSSHKey(
// Try SSH2 first if available
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
try {
console.log("Calling ssh2Utils.parseKey...");
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
console.log(
"parseKey returned:",
typeof parsedKey,
parsedKey instanceof Error ? parsedKey.message : "success",
);
if (!(parsedKey instanceof Error)) {
// Extract key type
if (parsedKey.type) {
keyType = parsedKey.type;
}
console.log("Extracted key type:", keyType);
// Generate public key in SSH format
try {
console.log("Attempting to generate public key...");
const publicKeyBuffer = parsedKey.getPublicSSH();
console.log("Public key buffer type:", typeof publicKeyBuffer);
console.log(
"Public key buffer is Buffer:",
Buffer.isBuffer(publicKeyBuffer),
);
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
// We need to convert this to proper SSH public key format
@@ -304,53 +279,26 @@ export function parseSSHKey(
} else {
publicKey = `${keyType} ${base64Data}`;
}
console.log(
"Generated SSH public key format, length:",
publicKey.length,
);
console.log(
"Public key starts with:",
publicKey.substring(0, 50),
);
} else {
console.warn("Unexpected public key buffer type");
publicKey = "";
}
} catch (error) {
console.warn("Failed to generate public key:", error);
publicKey = "";
}
useSSH2 = true;
console.log(`SSH key parsed successfully with SSH2: ${keyType}`);
} else {
console.warn("SSH2 parsing failed:", parsedKey.message);
}
} catch (error) {
console.warn(
"SSH2 parsing exception:",
error instanceof Error ? error.message : error,
);
// SSH2 parsing failed, will fall back to content detection
}
} else {
console.warn("SSH2 parseKey function not available");
}
// Fallback to content-based detection
if (!useSSH2) {
console.log("Using fallback key type detection...");
keyType = detectKeyTypeFromContent(privateKeyData);
console.log(`Fallback detected key type: ${keyType}`);
// For fallback, we can't generate public key but the detection is still useful
publicKey = "";
if (keyType !== "unknown") {
console.log(
`SSH key type detected successfully with fallback: ${keyType}`,
);
}
}
return {
@@ -360,17 +308,10 @@ export function parseSSHKey(
success: keyType !== "unknown",
};
} catch (error) {
console.error("Exception during SSH key parsing:", error);
console.error(
"Error stack:",
error instanceof Error ? error.stack : "No stack",
);
// Final fallback - try content detection
try {
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
if (fallbackKeyType !== "unknown") {
console.log(`Final fallback detection successful: ${fallbackKeyType}`);
return {
privateKey: privateKeyData,
publicKey: "",
@@ -379,7 +320,7 @@ export function parseSSHKey(
};
}
} catch (fallbackError) {
console.error("Even fallback detection failed:", fallbackError);
// Even fallback detection failed
}
return {
@@ -397,16 +338,8 @@ export function parseSSHKey(
* Parse SSH public key and extract type information
*/
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
console.log("=== SSH Public Key Parsing Debug ===");
console.log("Public key length:", publicKeyData?.length || "undefined");
console.log(
"First 100 chars:",
publicKeyData?.substring(0, 100) || "undefined",
);
try {
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
console.log(`Public key type detected: ${keyType}`);
return {
publicKey: publicKeyData,
@@ -414,7 +347,6 @@ export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
success: keyType !== "unknown",
};
} catch (error) {
console.error("Exception during SSH public key parsing:", error);
return {
publicKey: publicKeyData,
keyType: "unknown",
@@ -469,26 +401,11 @@ export function validateKeyPair(
publicKeyData: string,
passphrase?: string,
): KeyPairValidationResult {
console.log("=== Key Pair Validation Debug ===");
console.log("Private key length:", privateKeyData?.length || "undefined");
console.log("Public key length:", publicKeyData?.length || "undefined");
try {
// First parse the private key and try to generate public key
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
const publicKeyInfo = parsePublicKey(publicKeyData);
console.log(
"Private key parsing result:",
privateKeyInfo.success,
privateKeyInfo.keyType,
);
console.log(
"Public key parsing result:",
publicKeyInfo.success,
publicKeyInfo.keyType,
);
if (!privateKeyInfo.success) {
return {
isValid: false,
@@ -522,9 +439,6 @@ export function validateKeyPair(
const generatedPublicKey = privateKeyInfo.publicKey.trim();
const providedPublicKey = publicKeyData.trim();
console.log("Generated public key length:", generatedPublicKey.length);
console.log("Provided public key length:", providedPublicKey.length);
// Compare the key data part (excluding comments)
const generatedKeyParts = generatedPublicKey.split(" ");
const providedKeyParts = providedPublicKey.split(" ");
@@ -535,15 +449,6 @@ export function validateKeyPair(
generatedKeyParts[0] + " " + generatedKeyParts[1];
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
console.log(
"Generated key data:",
generatedKeyData.substring(0, 50) + "...",
);
console.log(
"Provided key data:",
providedKeyData.substring(0, 50) + "...",
);
if (generatedKeyData === providedKeyData) {
return {
isValid: true,
@@ -571,7 +476,6 @@ export function validateKeyPair(
error: "Unable to verify key pair match, but key types are compatible",
};
} catch (error) {
console.error("Exception during key pair validation:", error);
return {
isValid: false,
privateKeyType: "unknown",

View File

@@ -41,10 +41,6 @@ class SystemCrypto {
const envSecret = process.env.JWT_SECRET;
if (envSecret && envSecret.length >= 64) {
this.jwtSecret = envSecret;
databaseLogger.info("✅ Using JWT secret from environment variable", {
operation: "jwt_env_loaded",
source: "environment"
});
return;
}
@@ -82,10 +78,6 @@ class SystemCrypto {
const envKey = process.env.DATABASE_KEY;
if (envKey && envKey.length >= 64) {
this.databaseKey = Buffer.from(envKey, 'hex');
databaseLogger.info("✅ Using database key from environment variable", {
operation: "db_key_env_loaded",
source: "environment"
});
return;
}
@@ -123,10 +115,6 @@ class SystemCrypto {
const envToken = process.env.INTERNAL_AUTH_TOKEN;
if (envToken && envToken.length >= 32) {
this.internalAuthToken = envToken;
databaseLogger.info("✅ Using internal auth token from environment variable", {
operation: "internal_auth_env_loaded",
source: "environment"
});
return;
}
@@ -164,7 +152,7 @@ class SystemCrypto {
// Auto-save to .env file
await this.updateEnvFile("JWT_SECRET", newSecret);
databaseLogger.success("🔐 JWT secret auto-generated and saved to .env", {
databaseLogger.success("JWT secret auto-generated and saved to .env", {
operation: "jwt_auto_generated",
instanceId,
envVarName: "JWT_SECRET",
@@ -210,7 +198,7 @@ class SystemCrypto {
// Auto-save to .env file
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
databaseLogger.success("🔑 Internal auth token auto-generated and saved to .env", {
databaseLogger.success("Internal auth token auto-generated and saved to .env", {
operation: "internal_auth_auto_generated",
instanceId,
envVarName: "INTERNAL_AUTH_TOKEN",

View File

@@ -87,7 +87,7 @@
"keyPassphraseOptional": "Optional: leave empty if your key has no passphrase",
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
"uploadKeyFile": "Upload Key File",
"generateKeyPair": "Generate Key Pair",
"generateKeyPairButton": "Generate Key Pair",
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
"connectionTestingNotImplemented": "Connection testing feature coming soon",
"testConnection": "Test Connection",
@@ -123,7 +123,7 @@
"editCredentialDescription": "Update the credential information",
"listView": "List",
"folderView": "Folders",
"unknown": "Unknown",
"unknownCredential": "Unknown",
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The credential will be moved to \"Uncategorized\".",
"removedFromFolder": "Credential \"{{name}}\" removed from folder successfully",
"failedToRemoveFromFolder": "Failed to remove credential from folder",
@@ -144,7 +144,7 @@
"detectedKeyType": "Detected key type",
"detectingKeyType": "detecting...",
"optional": "Optional",
"generateKeyPair": "Generate New Key Pair",
"generateKeyPairNew": "Generate New Key Pair",
"generateEd25519": "Generate Ed25519",
"generateECDSA": "Generate ECDSA",
"generateRSA": "Generate RSA",
@@ -155,6 +155,16 @@
"detectionError": "Detection Error",
"unknown": "Unknown"
},
"dragIndicator": {
"error": "Error: {{error}}",
"dragging": "Dragging {{fileName}}",
"preparing": "Preparing {{fileName}}",
"readySingle": "Ready to download {{fileName}}",
"readyMultiple": "Ready to download {{count}} files",
"batchDrag": "Drag {{count}} files to desktop",
"dragToDesktop": "Drag to desktop",
"canDragAnywhere": "You can drag files anywhere on your desktop"
},
"sshTools": {
"title": "SSH Tools",
"closeTools": "Close SSH Tools",
@@ -190,7 +200,7 @@
"saveError": "Error saving configuration",
"saving": "Saving...",
"saveConfig": "Save Configuration",
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:8081 or https://your-server.com)"
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)"
},
"common": {
"close": "Close",
@@ -294,7 +304,10 @@
"failedToInitiatePasswordReset": "Failed to initiate password reset",
"failedToVerifyResetCode": "Failed to verify reset code",
"failedToCompletePasswordReset": "Failed to complete password reset",
"documentation": "Documentation"
"documentation": "Documentation",
"retry": "Retry",
"checking": "Checking...",
"checkingDatabase": "Checking database connection..."
},
"nav": {
"home": "Home",
@@ -719,6 +732,9 @@
"failedToCreateFile": "Failed to create file",
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
"failedToCreateFolder": "Failed to create folder",
"failedToCreateItem": "Failed to create item",
"operationFailed": "{{operation}} operation failed for {{name}}: {{error}}",
"failedToResolveSymlink": "Failed to resolve symlink",
"itemDeletedSuccessfully": "{{type}} deleted successfully",
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
"failedToDeleteItems": "Failed to delete items",
@@ -774,7 +790,7 @@
"serverError": "Server Error",
"error": "Error",
"requestFailed": "Request failed with status code",
"unknown": "unknown",
"unknownFileError": "unknown",
"cannotReadFile": "Cannot read file",
"noSshSessionId": "No SSH session ID available",
"noFilePath": "No file path available",
@@ -925,7 +941,7 @@
"disconnected": "Disconnected",
"connecting": "Connecting...",
"disconnecting": "Disconnecting...",
"unknown": "Unknown",
"unknownTunnelStatus": "Unknown",
"error": "Error",
"failed": "Failed",
"retrying": "Retrying",
@@ -962,7 +978,7 @@
"dynamic": "Dynamic",
"noSshTunnels": "No SSH Tunnels",
"createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.",
"unknown": "Unknown",
"unknownConnectionStatus": "Unknown",
"connected": "Connected",
"connecting": "Connecting...",
"disconnecting": "Disconnecting...",
@@ -1105,7 +1121,7 @@
"forbidden": "Access forbidden",
"serverError": "Server error",
"networkError": "Network error",
"databaseConnection": "Could not connect to the database. Please try again later.",
"databaseConnection": "Could not connect to the database.",
"unknownError": "Unknown error",
"failedPasswordReset": "Failed to initiate password reset",
"failedVerifyCode": "Failed to verify reset code",
@@ -1143,7 +1159,15 @@
"reconnecting": "Reconnecting...",
"processing": "Processing...",
"pleaseWait": "Please wait...",
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator."
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
"databaseConnected": "Database connected successfully",
"databaseConnectionFailed": "Failed to connect to the database server",
"checkServerConnection": "Please check your server connection and try again",
"resetCodeSent": "Reset code sent to your email",
"codeVerified": "Code verified successfully",
"passwordResetSuccess": "Password reset successfully",
"loginSuccess": "Login successful",
"registrationSuccess": "Registration successful"
},
"profile": {
"title": "User Profile",
@@ -1306,6 +1330,9 @@
"updateKey": "Update Key",
"productionFolder": "Production",
"databaseServer": "Database Server",
"developmentServer": "Development Server",
"developmentFolder": "Development",
"webServerProduction": "Web Server - Production",
"unknownError": "Unknown error",
"failedToInitiatePasswordReset": "Failed to initiate password reset",
"failedToVerifyResetCode": "Failed to verify reset code",

View File

@@ -87,7 +87,7 @@
"keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空",
"leaveEmptyToKeepCurrent": "留空以保持当前值",
"uploadKeyFile": "上传密钥文件",
"generateKeyPair": "生成密钥对",
"generateKeyPairButton": "生成密钥对",
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
"connectionTestingNotImplemented": "连接测试功能即将推出",
"testConnection": "测试连接",
@@ -122,7 +122,7 @@
"editCredentialDescription": "更新凭据信息",
"listView": "列表",
"folderView": "文件夹",
"unknown": "未知",
"unknownCredential": "未知",
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?凭据将被移动到\"未分类\"。",
"removedFromFolder": "凭据\"{{name}}\"已成功从文件夹中移除",
"failedToRemoveFromFolder": "从文件夹中移除凭据失败",
@@ -143,7 +143,7 @@
"detectedKeyType": "检测到的密钥类型",
"detectingKeyType": "检测中...",
"optional": "可选",
"generateKeyPair": "生成新的密钥对",
"generateKeyPairNew": "生成新的密钥对",
"generateEd25519": "生成 Ed25519",
"generateECDSA": "生成 ECDSA",
"generateRSA": "生成 RSA",
@@ -151,8 +151,17 @@
"failedToGenerateKeyPair": "生成密钥对失败",
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。",
"invalidKey": "无效密钥",
"detectionError": "检测错误",
"unknown": "未知"
"detectionError": "检测错误"
},
"dragIndicator": {
"error": "错误:{{error}}",
"dragging": "正在拖拽 {{fileName}}",
"preparing": "正在准备 {{fileName}}",
"readySingle": "准备下载 {{fileName}}",
"readyMultiple": "准备下载 {{count}} 个文件",
"batchDrag": "拖拽 {{count}} 个文件到桌面",
"dragToDesktop": "拖拽到桌面",
"canDragAnywhere": "您可以将文件拖拽到桌面的任何位置"
},
"sshTools": {
"title": "SSH 工具",
@@ -189,7 +198,7 @@
"saveError": "保存配置时出错",
"saving": "保存中...",
"saveConfig": "保存配置",
"helpText": "输入您的 Termix 服务器运行地址例如http://localhost:8081 或 https://your-server.com"
"helpText": "输入您的 Termix 服务器运行地址例如http://localhost:30001 或 https://your-server.com"
},
"common": {
"close": "关闭",
@@ -281,7 +290,10 @@
"failedToInitiatePasswordReset": "启动密码重置失败",
"failedToVerifyResetCode": "验证重置代码失败",
"failedToCompletePasswordReset": "完成密码重置失败",
"documentation": "文档"
"documentation": "文档",
"retry": "重试",
"checking": "检查中...",
"checkingDatabase": "正在检查数据库连接..."
},
"nav": {
"home": "首页",
@@ -742,6 +754,9 @@
"failedToCreateFile": "创建文件失败",
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
"failedToCreateFolder": "创建文件夹失败",
"failedToCreateItem": "创建项目失败",
"operationFailed": "{{operation}} 操作失败,文件 {{name}}{{error}}",
"failedToResolveSymlink": "解析符号链接失败",
"itemDeletedSuccessfully": "{{type}}删除成功",
"itemsDeletedSuccessfully": "{{count}} 个项目删除成功",
"failedToDeleteItems": "删除项目失败",
@@ -800,7 +815,7 @@
"serverError": "服务器错误",
"error": "错误",
"requestFailed": "请求失败,状态码",
"unknown": "未知",
"unknownFileError": "未知",
"cannotReadFile": "无法读取文件",
"noSshSessionId": "没有可用的 SSH 会话 ID",
"noFilePath": "没有可用的文件路径",
@@ -940,7 +955,7 @@
"disconnected": "已断开连接",
"connecting": "连接中...",
"disconnecting": "断开连接中...",
"unknown": "未知",
"unknownTunnelStatus": "未知",
"error": "错误",
"failed": "失败",
"retrying": "重试中",
@@ -1106,7 +1121,7 @@
"forbidden": "访问被禁止",
"serverError": "服务器错误",
"networkError": "网络错误",
"databaseConnection": "无法连接到数据库。请稍后再试。",
"databaseConnection": "无法连接到数据库。",
"unknownError": "未知错误",
"failedPasswordReset": "无法启动密码重置",
"failedVerifyCode": "验证重置代码失败",
@@ -1144,7 +1159,15 @@
"reconnecting": "重新连接中...",
"processing": "处理中...",
"pleaseWait": "请稍候...",
"registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。"
"registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。",
"databaseConnected": "数据库连接成功",
"databaseConnectionFailed": "无法连接到数据库服务器",
"checkServerConnection": "请检查您的服务器连接并重试",
"resetCodeSent": "重置代码已发送到您的邮箱",
"codeVerified": "代码验证成功",
"passwordResetSuccess": "密码重置成功",
"loginSuccess": "登录成功",
"registrationSuccess": "注册成功"
},
"profile": {
"title": "用户资料",
@@ -1308,6 +1331,9 @@
"sshServerConfigRequired": "需要 SSH 服务器配置",
"productionFolder": "生产环境",
"databaseServer": "数据库服务器",
"developmentServer": "开发服务器",
"developmentFolder": "开发环境",
"webServerProduction": "Web 服务器 - 生产环境",
"unknownError": "未知错误",
"failedToInitiatePasswordReset": "启动密码重置失败",
"failedToVerifyResetCode": "验证重置代码失败",

View File

@@ -383,26 +383,6 @@ export interface FileManagerProps {
initialHost?: SSHHost | null;
}
export interface FileManagerLeftSidebarProps {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: Tab[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
}
export interface FileManagerOperationsProps {
currentPath: string;
sshSessionId: string | null;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
}
export interface AlertCardProps {
alert: TermixAlert;
onDismiss: (alertId: string) => void;

View File

@@ -295,7 +295,7 @@ export function AdminSettings({
const jwt = getCookie("jwt");
const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/export`
: "http://localhost:8081/database/export";
: "http://localhost:30001/database/export";
const response = await fetch(apiUrl, {
method: "POST",
@@ -355,7 +355,7 @@ export function AdminSettings({
const jwt = getCookie("jwt");
const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/import`
: "http://localhost:8081/database/import";
: "http://localhost:30001/database/import";
// Create FormData for file upload
const formData = new FormData();
@@ -927,12 +927,29 @@ export function AdminSettings({
<p className="text-xs text-muted-foreground">
{t("admin.importDescription")}
</p>
<input
type="file"
accept=".sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground mb-2"
/>
<div className="relative inline-block w-full mb-2">
<input
id="import-file-upload"
type="file"
accept=".sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span
className="truncate"
title={importFile?.name || t("admin.pleaseSelectImportFile")}
>
{importFile
? importFile.name
: t("admin.pleaseSelectImportFile")}
</span>
</Button>
</div>
{importFile && (
<div className="space-y-2">
<Label htmlFor="import-password">Password</Label>

View File

@@ -1,26 +0,0 @@
import React from "react";
import { FileManagerTabList } from "./FileManagerTabList.tsx";
interface FileManagerTopNavbarProps {
tabs: { id: string | number; title: string }[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FIleManagerTopNavbar(
props: FileManagerTopNavbarProps,
): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
return (
<FileManagerTabList
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={onHomeClick}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -257,14 +257,14 @@ export function FileManagerContextMenu({
});
}
// Download function - unified download that uses best available method
if (hasFiles && onDragToDesktop) {
// Download function - use proper download handler
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDragToDesktop(),
action: () => onDownload(files),
shortcut: "Ctrl+D",
});
}

View File

@@ -1,348 +0,0 @@
import React, { useEffect } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
interface FileManagerCodeEditorProps {
content: string;
fileName: string;
onContentChange: (value: string) => void;
}
export function FileManagerFileEditor({
content,
fileName,
onContentChange,
}: FileManagerCodeEditorProps) {
function getLanguageName(filename: string): string {
if (!filename || typeof filename !== "string") {
return "text";
}
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) {
return "text";
}
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) {
case "ng":
return "angular";
case "apl":
return "apl";
case "asc":
return "asciiArmor";
case "ast":
return "asterisk";
case "bf":
return "brainfuck";
case "c":
return "c";
case "ceylon":
return "ceylon";
case "clj":
return "clojure";
case "cmake":
return "cmake";
case "cob":
case "cbl":
return "cobol";
case "coffee":
return "coffeescript";
case "lisp":
return "commonLisp";
case "cpp":
case "cc":
case "cxx":
return "cpp";
case "cr":
return "crystal";
case "cs":
return "csharp";
case "css":
return "css";
case "cypher":
return "cypher";
case "d":
return "d";
case "dart":
return "dart";
case "diff":
case "patch":
return "diff";
case "dockerfile":
return "dockerfile";
case "dtd":
return "dtd";
case "dylan":
return "dylan";
case "ebnf":
return "ebnf";
case "ecl":
return "ecl";
case "eiffel":
return "eiffel";
case "elm":
return "elm";
case "erl":
return "erlang";
case "factor":
return "factor";
case "fcl":
return "fcl";
case "fs":
return "forth";
case "f90":
case "for":
return "fortran";
case "s":
return "gas";
case "feature":
return "gherkin";
case "go":
return "go";
case "groovy":
return "groovy";
case "hs":
return "haskell";
case "hx":
return "haxe";
case "html":
case "htm":
return "html";
case "http":
return "http";
case "idl":
return "idl";
case "java":
return "java";
case "js":
case "mjs":
case "cjs":
return "javascript";
case "jinja2":
case "j2":
return "jinja2";
case "json":
return "json";
case "jsx":
return "jsx";
case "jl":
return "julia";
case "kt":
case "kts":
return "kotlin";
case "less":
return "less";
case "lezer":
return "lezer";
case "liquid":
return "liquid";
case "litcoffee":
return "livescript";
case "lua":
return "lua";
case "md":
return "markdown";
case "nb":
case "mat":
return "mathematica";
case "mbox":
return "mbox";
case "mmd":
return "mermaid";
case "mrc":
return "mirc";
case "moo":
return "modelica";
case "mscgen":
return "mscgen";
case "m":
return "mumps";
case "sql":
return "mysql";
case "nc":
return "nesC";
case "nginx":
return "nginx";
case "nix":
return "nix";
case "nsi":
return "nsis";
case "nt":
return "ntriples";
case "mm":
return "objectiveCpp";
case "octave":
return "octave";
case "oz":
return "oz";
case "pas":
return "pascal";
case "pl":
case "pm":
return "perl";
case "pgsql":
return "pgsql";
case "php":
return "php";
case "pig":
return "pig";
case "ps1":
return "powershell";
case "properties":
return "properties";
case "proto":
return "protobuf";
case "pp":
return "puppet";
case "py":
return "python";
case "q":
return "q";
case "r":
return "r";
case "rb":
return "ruby";
case "rs":
return "rust";
case "sas":
return "sas";
case "sass":
case "scss":
return "sass";
case "scala":
return "scala";
case "scm":
return "scheme";
case "shader":
return "shader";
case "sh":
case "bash":
return "shell";
case "siv":
return "sieve";
case "st":
return "smalltalk";
case "sol":
return "solidity";
case "solr":
return "solr";
case "rq":
return "sparql";
case "xlsx":
case "ods":
case "csv":
return "spreadsheet";
case "nut":
return "squirrel";
case "tex":
return "stex";
case "styl":
return "stylus";
case "svelte":
return "svelte";
case "swift":
return "swift";
case "tcl":
return "tcl";
case "textile":
return "textile";
case "tiddlywiki":
return "tiddlyWiki";
case "tiki":
return "tiki";
case "toml":
return "toml";
case "troff":
return "troff";
case "tsx":
return "tsx";
case "ttcn":
return "ttcn";
case "ttl":
case "turtle":
return "turtle";
case "ts":
return "typescript";
case "vb":
return "vb";
case "vbs":
return "vbscript";
case "vm":
return "velocity";
case "v":
return "verilog";
case "vhd":
case "vhdl":
return "vhdl";
case "vue":
return "vue";
case "wat":
return "wast";
case "webidl":
return "webIDL";
case "xq":
case "xquery":
return "xQuery";
case "xml":
return "xml";
case "yacas":
return "yacas";
case "yaml":
case "yml":
return "yaml";
case "z80":
return "z80";
default:
return "text";
}
}
useEffect(() => {
document.body.style.overflowX = "hidden";
return () => {
document.body.style.overflowX = "";
};
}, []);
return (
<div className="w-full h-full relative overflow-hidden flex flex-col">
<div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
<CodeMirror
value={content}
extensions={[
loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
[],
hyperLink,
oneDark,
EditorView.theme({
"&": {
backgroundColor: "var(--color-dark-bg-darkest) !important",
height: "100%",
},
".cm-gutters": {
backgroundColor: "var(--color-dark-bg) !important",
},
".cm-scroller": {
overflow: "auto",
},
".cm-editor": {
height: "100%",
},
}),
]}
onChange={(value: any) => onContentChange(value)}
theme={undefined}
height="100%"
basicSetup={{
lineNumbers: true,
scrollPastEnd: false,
}}
className="min-h-full min-w-full flex-1"
/>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import {
Folder,
@@ -60,6 +61,7 @@ function formatFileSize(bytes?: number): string {
interface DragState {
type: "none" | "internal" | "external";
files: FileItem[];
draggedFiles?: FileItem[];
target?: FileItem;
counter: number;
mousePosition?: { x: number; y: number };
@@ -91,7 +93,7 @@ interface FileManagerGridProps {
onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void;
onFileDiff?: (file1: FileItem, file2: FileItem) => void;
onSystemDragStart?: (files: FileItem[]) => void;
onSystemDragEnd?: (e: DragEvent) => void;
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
hasClipboard?: boolean;
// Linus-style creation intent props
createIntent?: CreateIntent | null;
@@ -283,6 +285,7 @@ export function FileManagerGrid({
setDragState({
type: "internal",
files: filesToDrag,
draggedFiles: filesToDrag,
counter: 0,
mousePosition: { x: e.clientX, y: e.clientY },
});
@@ -293,9 +296,6 @@ export function FileManagerGrid({
files: filesToDrag.map((f) => f.path),
};
e.dataTransfer.setData("text/plain", JSON.stringify(dragData));
// Trigger system-level drag start
onSystemDragStart?.(filesToDrag);
e.dataTransfer.effectAllowed = "move";
};
@@ -378,10 +378,11 @@ export function FileManagerGrid({
};
const handleFileDragEnd = (e: React.DragEvent) => {
const draggedFiles = dragState.draggedFiles || [];
setDragState({ type: "none", files: [], counter: 0 });
// Trigger system-level drag end detection
onSystemDragEnd?.(e.nativeEvent);
// Trigger system-level drag end detection with dragged files
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
};
const [isSelecting, setIsSelecting] = useState(false);
@@ -1356,44 +1357,50 @@ export function FileManagerGrid({
</div>
</div>
{/* Drag following tooltip */}
{/* Drag following tooltip - rendered as portal to ensure highest z-index */}
{dragState.type === "internal" &&
dragState.files.length > 0 &&
dragState.mousePosition && (
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
dragState.mousePosition &&
createPortal(
<div
className="fixed z-[99999] pointer-events-none"
className="fixed pointer-events-none"
style={{
left: dragState.mousePosition.x + 24,
top: dragState.mousePosition.y - 40,
left: Math.min(Math.max(dragState.mousePosition.x + 40, 10), window.innerWidth - 300),
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 10),
zIndex: 999999,
}}
>
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
{dragState.target ? (
dragState.target.type === "directory" ? (
<>
<Move className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-foreground">
Move to {dragState.target.name}
</span>
</>
{(() => {
const files = dragState.files.length > 0 ? dragState.files : dragState.draggedFiles || [];
return dragState.target ? (
dragState.target.type === "directory" ? (
<>
<Move className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-foreground">
Move to {dragState.target.name}
</span>
</>
) : (
<>
<GitCompare className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-foreground">
Diff compare with {dragState.target.name}
</span>
</>
)
) : (
<>
<GitCompare className="w-4 h-4 text-purple-500" />
<Download className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
Diff compare with {dragState.target.name}
Drag outside window to download ({files.length} files)
</span>
</>
)
) : (
<>
<Download className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
Drag outside window to download ({dragState.files.length} files)
</span>
</>
)}
);
})()}
</div>
</div>
</div>,
document.body
)}
</div>
);

View File

@@ -1,234 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs.tsx";
import { Input } from "@/components/ui/input.tsx";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { FileItem, ShortcutItem } from "../../../types/index";
interface FileManagerHomeViewProps {
recent: FileItem[];
pinned: FileItem[];
shortcuts: ShortcutItem[];
onOpenFile: (file: FileItem) => void;
onRemoveRecent: (file: FileItem) => void;
onPinFile: (file: FileItem) => void;
onUnpinFile: (file: FileItem) => void;
onOpenShortcut: (shortcut: ShortcutItem) => void;
onRemoveShortcut: (shortcut: ShortcutItem) => void;
onAddShortcut: (path: string) => void;
}
export function FileManagerHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut,
}: FileManagerHomeViewProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
const [newShortcut, setNewShortcut] = useState("");
const renderFileCard = (
file: FileItem,
onRemove: () => void,
onPin?: () => void,
isPinned = false,
) => (
<div
key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)}
>
{file.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{file.name}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onPin && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onPin}
>
<Pin
className={`w-3 h-3 ${isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button>
)}
{onRemove && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onRemove}
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
)}
</div>
</div>
);
const renderShortcutCard = (shortcut: ShortcutItem) => (
<div
key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)}
>
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{shortcut.path}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={() => onRemoveShortcut(shortcut)}
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
</div>
</div>
);
return (
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
className="w-full"
>
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger
value="recent"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.recent")}
</TabsTrigger>
<TabsTrigger
value="pinned"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.pinned")}
</TabsTrigger>
<TabsTrigger
value="shortcuts"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.folderShortcuts")}
</TabsTrigger>
</TabsList>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noRecentFiles")}
</span>
</div>
) : (
recent.map((file) =>
renderFileCard(
file,
() => onRemoveRecent(file),
() => (file.isPinned ? onUnpinFile(file) : onPinFile(file)),
file.isPinned,
),
)
)}
</div>
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noPinnedFiles")}
</span>
</div>
) : (
pinned.map((file) =>
renderFileCard(file, undefined, () => onUnpinFile(file), true),
)
)}
</div>
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
<Input
placeholder={t("fileManager.enterFolderPath")}
value={newShortcut}
onChange={(e) => setNewShortcut(e.target.value)}
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === "Enter" && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut("");
}
}}
/>
<Button
size="sm"
variant="ghost"
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
onClick={() => {
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut("");
}
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
{t("common.add")}
</Button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noShortcuts")}
</span>
</div>
) : (
shortcuts.map((shortcut) => renderShortcutCard(shortcut))
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,709 +0,0 @@
import React, {
useEffect,
useState,
useRef,
forwardRef,
useImperativeHandle,
} from "react";
import {
Folder,
File,
FileSymlink,
ArrowUp,
Pin,
MoreVertical,
Trash2,
Edit3,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { cn } from "@/lib/utils.ts";
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
listSSHFiles,
renameSSHItem,
deleteSSHItem,
getFileManagerPinned,
addFileManagerPinned,
removeFileManagerPinned,
getSSHStatus,
connectSSH,
identifySSHSymlink,
} from "@/ui/main-axios.ts";
import type { SSHHost } from "../../../types/index.js";
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{
onOpenFile,
tabs,
host,
onOperationComplete,
onPathChange,
onDeleteItem,
}: {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: any[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
},
ref,
) {
const { t } = useTranslation();
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileSearch, setFileSearch] = useState("");
const [debouncedFileSearch, setDebouncedFileSearch] = useState("");
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
return () => clearTimeout(handler);
}, [fileSearch]);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false);
const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<
Record<
string,
{
sessionId: string;
timestamp: number;
}
>
>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
item: any;
}>({
visible: false,
x: 0,
y: 0,
item: null,
});
const [renamingItem, setRenamingItem] = useState<{
item: any;
newName: string;
} | null>(null);
useEffect(() => {
const nextPath = host?.defaultPath || "/";
setCurrentPath(nextPath);
onPathChange?.(nextPath);
(async () => {
await connectToSSH(host);
})();
}, [host?.id]);
async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString();
const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) {
setSshSessionId(cached.sessionId);
return cached.sessionId;
}
if (connectingSSH) {
return null;
}
setConnectingSSH(true);
try {
if (!server.password && !server.key) {
toast.error(t("common.noAuthCredentials"));
return null;
}
const connectionConfig = {
hostId: server.id,
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
sshKey: server.key,
keyPassword: server.keyPassword,
authType: server.authType,
credentialId: server.credentialId,
userId: server.userId,
};
await connectSSH(sessionId, connectionConfig);
setSshSessionId(sessionId);
setConnectionCache((prev) => ({
...prev,
[sessionId]: { sessionId, timestamp: Date.now() },
}));
return sessionId;
} catch (err: any) {
toast.error(
err?.response?.data?.error || t("fileManager.failedToConnectSSH"),
);
setSshSessionId(null);
return null;
} finally {
setConnectingSSH(false);
}
}
async function fetchFiles() {
if (fetchingFiles) {
return;
}
setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
try {
let pinnedFiles: any[] = [];
try {
if (host) {
pinnedFiles = await getFileManagerPinned(host.id);
}
} catch (err) {}
if (host && sshSessionId) {
let res: any[] = [];
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw new Error(t("fileManager.failedToReconnectSSH"));
}
} else {
res = await listSSHFiles(sshSessionId, currentPath);
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw sessionErr;
}
}
const processedFiles = (res || []).map((f: any) => {
const filePath =
currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name;
const isPinned = pinnedFiles.some(
(pinned) => pinned.path === filePath,
);
return {
...f,
path: filePath,
isPinned,
isSSH: true,
sshSessionId: sshSessionId,
};
});
setFiles(processedFiles);
}
} catch (err: any) {
setFiles([]);
toast.error(
err?.response?.data?.error ||
err?.message ||
t("fileManager.failedToListFiles"),
);
} finally {
setFilesLoading(false);
setFetchingFiles(false);
}
}
useEffect(() => {
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
const timeoutId = setTimeout(() => {
fetchFiles();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [currentPath, host, sshSessionId]);
useImperativeHandle(ref, () => ({
openFolder: async (_server: SSHHost, path: string) => {
if (connectingSSH || fetchingFiles) {
return;
}
if (currentPath === path) {
setTimeout(() => fetchFiles(), 100);
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFiles([]);
setCurrentPath(path);
onPathChange?.(path);
if (!sshSessionId) {
const sessionId = await connectToSSH(host);
if (sessionId) setSshSessionId(sessionId);
}
},
fetchFiles: () => {
if (host && sshSessionId) {
fetchFiles();
}
},
getCurrentPath: () => currentPath,
}));
useEffect(() => {
if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
}
}, [currentPath]);
const filteredFiles = files.filter((file) => {
const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true;
return file.name.toLowerCase().includes(q);
});
const handleContextMenu = (e: React.MouseEvent, item: any) => {
e.preventDefault();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = 160;
const menuHeight = 80;
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > viewportWidth) {
x = e.clientX - menuWidth;
}
if (y + menuHeight > viewportHeight) {
y = e.clientY - menuHeight;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
setContextMenu({
visible: true,
x,
y,
item,
});
};
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, item: null });
};
const handleRename = async (item: any, newName: string) => {
if (!sshSessionId || !newName.trim() || newName === item.name) {
setRenamingItem(null);
return;
}
try {
await renameSSHItem(sshSessionId, item.path, newName.trim());
toast.success(
`${item.type === "directory" ? t("common.folder") : item.type === "link" ? t("common.link") : t("common.file")} ${t("common.renamedSuccessfully")}`,
);
setRenamingItem(null);
if (onOperationComplete) {
onOperationComplete();
} else {
fetchFiles();
}
} catch (error: any) {
toast.error(
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
);
}
};
const startRename = (item: any) => {
setRenamingItem({ item, newName: item.name });
closeContextMenu();
};
const startDelete = (item: any) => {
onDeleteItem?.(item);
closeContextMenu();
};
useEffect(() => {
const handleClickOutside = () => closeContextMenu();
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
const handlePathChange = (newPath: string) => {
setCurrentPath(newPath);
onPathChange?.(newPath);
};
// Handle symlink resolution
const handleSymlinkClick = async (item: any) => {
if (!host) return;
try {
// Extract just the symlink path (before the " -> " if present)
const symlinkPath = item.path.includes(" -> ")
? item.path.split(" -> ")[0]
: item.path;
let currentSessionId = sshSessionId;
// Check SSH connection status and reconnect if needed
if (currentSessionId) {
try {
const status = await getSSHStatus(currentSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw new Error(t("fileManager.failedToReconnectSSH"));
}
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw sessionErr;
}
}
} else {
// No session ID, try to connect
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw new Error(t("fileManager.failedToConnectSSH"));
}
}
const symlinkInfo = await identifySSHSymlink(
currentSessionId,
symlinkPath,
);
if (symlinkInfo.type === "directory") {
// If symlink points to a directory, navigate to it
handlePathChange(symlinkInfo.target);
} else if (symlinkInfo.type === "file") {
// If symlink points to a file, open it as a file
onOpenFile({
name: item.name,
path: symlinkInfo.target, // Use the target path, not the symlink path
isSSH: item.isSSH,
sshSessionId: currentSessionId,
});
}
} catch (error: any) {
toast.error(
error?.response?.data?.error ||
error?.message ||
t("fileManager.failedToResolveSymlink"),
);
}
};
return (
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
<div className="flex flex-col flex-grow min-h-0">
<div className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
{host && (
<div className="flex flex-col h-full w-full max-w-[260px]">
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
<Button
size="icon"
variant="outline"
className="h-9 w-9 bg-dark-bg border-2 border-dark-border rounded-md hover:bg-dark-hover focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => {
let path = currentPath;
if (path && path !== "/" && path !== "") {
if (path.endsWith("/")) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf("/");
if (lastSlash > 0) {
handlePathChange(path.slice(0, lastSlash));
} else {
handlePathChange("/");
}
} else {
handlePathChange("/");
}
}}
>
<ArrowUp className="w-4 h-4" />
</Button>
<Input
ref={pathInputRef}
value={currentPath}
onChange={(e) => handlePathChange(e.target.value)}
className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light"
/>
</div>
<div className="px-2 py-2 border-b-1 border-dark-border bg-dark-bg">
<Input
placeholder={t("fileManager.searchFilesAndFolders")}
className="w-full h-7 text-sm bg-dark-bg-button border-2 border-dark-border-hover text-white placeholder:text-muted-foreground rounded-md"
autoComplete="off"
value={fileSearch}
onChange={(e) => setFileSearch(e.target.value)}
/>
</div>
<div className="flex-1 min-h-0 w-full bg-dark-bg-darkest border-t-1 border-dark-border">
<ScrollArea className="h-full w-full bg-dark-bg-darkest">
<div className="p-2 pb-0">
{connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">
{t("fileManager.noFilesOrFoldersFound")}
</div>
) : (
<div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some(
(t: any) => t.id === item.path,
);
const isRenaming =
renamingItem?.item?.path === item.path;
const isDeleting = false;
return (
<div
key={item.path}
className={cn(
"flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded group max-w-[220px] mb-2 relative",
isOpen &&
"opacity-60 cursor-not-allowed pointer-events-none",
)}
onContextMenu={(e) =>
!isOpen && handleContextMenu(e, item)
}
>
{isRenaming ? (
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : item.type === "link" ? (
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<Input
value={renamingItem.newName}
onChange={(e) =>
setRenamingItem((prev) =>
prev
? {
...prev,
newName: e.target.value,
}
: null,
)
}
className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
handleRename(
item,
renamingItem.newName,
);
} else if (e.key === "Escape") {
setRenamingItem(null);
}
}}
onBlur={() =>
handleRename(item, renamingItem.newName)
}
/>
</div>
) : (
<>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() =>
!isOpen &&
(item.type === "directory"
? handlePathChange(item.path)
: item.type === "link"
? handleSymlinkClick(item)
: onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId,
}))
}
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : item.type === "link" ? (
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<span className="text-sm text-white truncate flex-1 min-w-0">
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
{item.type === "file" && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={isOpen}
onClick={async (e) => {
e.stopPropagation();
try {
if (item.isPinned) {
await removeFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId:
host?.id.toString(),
});
setFiles(
files.map((f) =>
f.path === item.path
? {
...f,
isPinned: false,
}
: f,
),
);
} else {
await addFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId:
host?.id.toString(),
});
setFiles(
files.map((f) =>
f.path === item.path
? {
...f,
isPinned: true,
}
: f,
),
);
}
} catch (err) {}
}}
>
<Pin
className={`w-1 h-1 ${item.isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button>
)}
{!isOpen && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleContextMenu(e, item);
}}
>
<MoreVertical className="w-4 h-4" />
</Button>
)}
</div>
</>
)}
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
)}
</div>
</div>
{contextMenu.visible && contextMenu.item && (
<div
className="fixed z-[99998] bg-dark-bg border-2 border-dark-border rounded-lg shadow-xl py-1 min-w-[160px]"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
>
<button
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2"
onClick={() => startRename(contextMenu.item)}
>
<Edit3 className="w-4 h-4" />
Rename
</button>
<button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2"
onClick={() => startDelete(contextMenu.item)}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
);
});
export { FileManagerLeftSidebar };

View File

@@ -1,141 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx";
import { Folder, File, Trash2, Pin, Download } from "lucide-react";
import { useTranslation } from "react-i18next";
interface SSHConnection {
id: string;
name: string;
ip: string;
port: number;
username: string;
isPinned?: boolean;
}
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
isStarred?: boolean;
}
interface FileManagerLeftSidebarVileViewerProps {
sshConnections: SSHConnection[];
onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void;
onEditSSH: (conn: SSHConnection) => void;
onDeleteSSH: (conn: SSHConnection) => void;
onPinSSH: (conn: SSHConnection) => void;
currentPath: string;
files: FileItem[];
onOpenFile: (file: FileItem) => void;
onOpenFolder: (folder: FileItem) => void;
onStarFile: (file: FileItem) => void;
onDownloadFile?: (file: FileItem) => void;
onDeleteFile: (file: FileItem) => void;
isLoading?: boolean;
error?: string;
isSSHMode: boolean;
onSwitchToLocal: () => void;
onSwitchToSSH: (conn: SSHConnection) => void;
currentSSH?: SSHConnection;
}
export function FileManagerLeftSidebarFileViewer({
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDownloadFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
}: FileManagerLeftSidebarVileViewerProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col h-full">
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground font-semibold">
{isSSHMode ? t("common.sshPath") : t("common.localPath")}
</span>
<span className="text-xs text-white truncate">{currentPath}</span>
</div>
{isLoading ? (
<div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : error ? (
<div className="text-xs text-red-500">{error}</div>
) : (
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card
key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() =>
item.type === "directory"
? onOpenFolder(item)
: onOpenFile(item)
}
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400" />
) : (
<File className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm text-white truncate">
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onStarFile(item)}
>
<Pin
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
/>
</Button>
{item.type === "file" && onDownloadFile && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onDownloadFile(item)}
title={t("fileManager.downloadFile")}
>
<Download className="w-4 h-4 text-blue-400" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onDeleteFile(item)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</Card>
))}
{files.length === 0 && (
<div className="text-xs text-muted-foreground">
No files or folders found.
</div>
)}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,980 +0,0 @@
import React, { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Card } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Upload,
Download,
FilePlus,
FolderPlus,
Trash2,
Edit3,
X,
AlertCircle,
FileText,
Folder,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import type { FileManagerOperationsProps } from "../../../types/index.js";
export function FileManagerOperations({
currentPath,
sshSessionId,
onOperationComplete,
onError,
onSuccess,
}: FileManagerOperationsProps) {
const { t } = useTranslation();
const [showUpload, setShowUpload] = useState(false);
const [showDownload, setShowDownload] = useState(false);
const [showCreateFile, setShowCreateFile] = useState(false);
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showRename, setShowRename] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [downloadPath, setDownloadPath] = useState("");
const [newFileName, setNewFileName] = useState("");
const [newFolderName, setNewFolderName] = useState("");
const [deletePath, setDeletePath] = useState("");
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
const [renamePath, setRenamePath] = useState("");
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
const [newName, setNewName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [showTextLabels, setShowTextLabels] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkContainerWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setShowTextLabels(width > 240);
}
};
checkContainerWidth();
const resizeObserver = new ResizeObserver(checkContainerWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
const handleFileUpload = async () => {
if (!uploadFile || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.uploadingFile", { name: uploadFile.name }),
);
try {
// Read file content - support text and binary files
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
// Check file type to determine reading method
const isTextFile =
uploadFile.type.startsWith("text/") ||
uploadFile.type === "application/json" ||
uploadFile.type === "application/javascript" ||
uploadFile.type === "application/xml" ||
uploadFile.name.match(
/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i,
);
if (isTextFile) {
reader.onload = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to read text file content"));
}
};
reader.readAsText(uploadFile);
} else {
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
const bytes = new Uint8Array(reader.result);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
resolve(base64);
} else {
reject(new Error("Failed to read binary file"));
}
};
reader.readAsArrayBuffer(uploadFile);
}
});
const { uploadSSHFile } = await import("@/ui/main-axios.ts");
const response = await uploadSSHFile(
sshSessionId,
currentPath,
uploadFile.name,
content,
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.fileUploadedSuccessfully", { name: uploadFile.name }),
);
}
setShowUpload(false);
setUploadFile(null);
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToUploadFile"),
);
} finally {
setIsLoading(false);
}
};
const handleCreateFile = async () => {
if (!newFileName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.creatingFile", { name: newFileName.trim() }),
);
try {
const { createSSHFile } = await import("@/ui/main-axios.ts");
const response = await createSSHFile(
sshSessionId,
currentPath,
newFileName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.fileCreatedSuccessfully", {
name: newFileName.trim(),
}),
);
}
setShowCreateFile(false);
setNewFileName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToCreateFile"),
);
} finally {
setIsLoading(false);
}
};
const handleDownload = async () => {
if (!downloadPath.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const fileName = downloadPath.split("/").pop() || "download";
const loadingToast = toast.loading(
t("fileManager.downloadingFile", { name: fileName }),
);
try {
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
const response = await downloadSSHFile(sshSessionId, downloadPath.trim());
toast.dismiss(loadingToast);
if (response?.content) {
// Convert base64 to blob and trigger download
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
onSuccess(
t("fileManager.fileDownloadedSuccessfully", {
name: response.fileName || fileName,
}),
);
} else {
onError(t("fileManager.noFileContent"));
}
setShowDownload(false);
setDownloadPath("");
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToDownloadFile"),
);
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.creatingFolder", { name: newFolderName.trim() }),
);
try {
const { createSSHFolder } = await import("@/ui/main-axios.ts");
const response = await createSSHFolder(
sshSessionId,
currentPath,
newFolderName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.folderCreatedSuccessfully", {
name: newFolderName.trim(),
}),
);
}
setShowCreateFolder(false);
setNewFolderName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToCreateFolder"),
);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!deletePath || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.deletingItem", {
type: deleteIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
name: deletePath.split("/").pop(),
}),
);
try {
const { deleteSSHItem } = await import("@/ui/main-axios.ts");
const response = await deleteSSHItem(
sshSessionId,
deletePath,
deleteIsDirectory,
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.itemDeletedSuccessfully", {
type: deleteIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
}),
);
}
setShowDelete(false);
setDeletePath("");
setDeleteIsDirectory(false);
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
);
} finally {
setIsLoading(false);
}
};
const handleRename = async () => {
if (!renamePath || !newName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.renamingItem", {
type: renameIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
oldName: renamePath.split("/").pop(),
newName: newName.trim(),
}),
);
try {
const { renameSSHItem } = await import("@/ui/main-axios.ts");
const response = await renameSSHItem(
sshSessionId,
renamePath,
newName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.itemRenamedSuccessfully", {
type: renameIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
}),
);
}
setShowRename(false);
setRenamePath("");
setRenameIsDirectory(false);
setNewName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
);
} finally {
setIsLoading(false);
}
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setUploadFile(file);
}
};
const resetStates = () => {
setShowUpload(false);
setShowCreateFile(false);
setShowCreateFolder(false);
setShowDelete(false);
setShowRename(false);
setUploadFile(null);
setNewFileName("");
setNewFolderName("");
setDeletePath("");
setDeleteIsDirectory(false);
setRenamePath("");
setRenameIsDirectory(false);
setNewName("");
};
if (!sshSessionId) {
return (
<div className="p-4 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{t("fileManager.connectToSsh")}
</p>
</div>
);
}
return (
<div ref={containerRef} className="p-4 space-y-4">
<div className="grid grid-cols-3 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowUpload(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.uploadFile")}
>
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.uploadFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDownload(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.downloadFile")}
>
<Download className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.downloadFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFile(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.newFile")}
>
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.newFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFolder(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.newFolder")}
>
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.newFolder")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRename(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.rename")}
>
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.rename")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDelete(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-3"
title={t("fileManager.deleteItem")}
>
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.deleteItem")}</span>
)}
</Button>
</div>
<div className="bg-dark-bg-light border-2 border-dark-border-medium rounded-md p-3">
<div className="flex items-start gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="text-muted-foreground block mb-1">
{t("fileManager.currentPath")}:
</span>
<span className="text-white font-mono text-xs break-all leading-relaxed">
{currentPath}
</span>
</div>
</div>
</div>
<Separator className="p-0.25 bg-dark-border" />
{showUpload && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.uploadFileTitle")}
</span>
</h3>
<p className="text-xs text-muted-foreground break-words">
{t("fileManager.maxFileSize")}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowUpload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div className="border-2 border-dashed border-dark-border-hover rounded-lg p-4 text-center">
{uploadFile ? (
<div className="space-y-3">
<FileText className="w-12 h-12 text-blue-400 mx-auto" />
<p className="text-white font-medium text-sm break-words px-2">
{uploadFile.name}
</p>
<p className="text-xs text-muted-foreground">
{(uploadFile.size / 1024).toFixed(2)} KB
</p>
<Button
variant="outline"
size="sm"
onClick={() => setUploadFile(null)}
className="w-full text-sm h-8"
>
{t("fileManager.removeFile")}
</Button>
</div>
) : (
<div className="space-y-3">
<Upload className="w-12 h-12 text-muted-foreground mx-auto" />
<p className="text-white text-sm break-words px-2">
{t("fileManager.clickToSelectFile")}
</p>
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
className="w-full text-sm h-8"
>
{t("fileManager.chooseFile")}
</Button>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<div className="flex flex-col gap-2">
<Button
onClick={handleFileUpload}
disabled={!uploadFile || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.uploading")
: t("fileManager.uploadFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowUpload(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showDownload && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<Download className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.downloadFile")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDownload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.filePath")}
</label>
<Input
value={downloadPath}
onChange={(e) => setDownloadPath(e.target.value)}
placeholder={t("placeholders.fullPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleDownload()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleDownload}
disabled={!downloadPath.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.downloading")
: t("fileManager.downloadFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowDownload(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showCreateFile && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.createNewFile")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFile(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.fileName")}
</label>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={t("placeholders.fileName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.creating")
: t("fileManager.createFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFile(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showCreateFolder && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<FolderPlus className="w-6 h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.createNewFolder")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFolder(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.folderName")}
</label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("placeholders.folderName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.creating")
: t("fileManager.createFolder")}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFolder(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showDelete && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0" />
<span className="break-words">
{t("fileManager.deleteItem")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDelete(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-start gap-2 text-red-300">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium break-words">
{t("fileManager.warningCannotUndo")}
</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.itemPath")}
</label>
<Input
value={deletePath}
onChange={(e) => setDeletePath(e.target.value)}
placeholder={t("placeholders.fullPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="deleteIsDirectory"
checked={deleteIsDirectory}
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
/>
<label
htmlFor="deleteIsDirectory"
className="text-sm text-white break-words"
>
{t("fileManager.thisIsDirectory")}
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleDelete}
disabled={!deletePath || isLoading}
variant="destructive"
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.deleting")
: t("fileManager.deleteItem")}
</Button>
<Button
variant="outline"
onClick={() => setShowDelete(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showRename && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Edit3 className="w-6 h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.renameItem")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRename(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.currentPathLabel")}
</label>
<Input
value={renamePath}
onChange={(e) => setRenamePath(e.target.value)}
placeholder={t("placeholders.currentPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
/>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.newName")}
</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("placeholders.newName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleRename()}
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="renameIsDirectory"
checked={renameIsDirectory}
onChange={(e) => setRenameIsDirectory(e.target.checked)}
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
/>
<label
htmlFor="renameIsDirectory"
className="text-sm text-white break-words"
>
{t("fileManager.thisIsDirectoryRename")}
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleRename}
disabled={!renamePath || !newName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.renaming")
: t("fileManager.renameItem")}
</Button>
<Button
variant="outline"
onClick={() => setShowRename(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -1,62 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { X, Home } from "lucide-react";
interface FileManagerTab {
id: string | number;
title: string;
}
interface FileManagerTabList {
tabs: FileManagerTab[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FileManagerTabList({
tabs,
activeTab,
setActiveTab,
closeTab,
onHomeClick,
}: FileManagerTabList) {
return (
<div className="inline-flex items-center h-full gap-2">
<Button
onClick={onHomeClick}
variant="outline"
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
>
<Home className="w-4 h-4" />
</Button>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<div
key={tab.id}
className="inline-flex rounded-md shadow-sm"
role="group"
>
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
>
{tab.title}
</Button>
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
>
<X className="!w-4 !h-4" strokeWidth={2} />
</Button>
</div>
);
})}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import {
@@ -15,6 +15,7 @@ import {
Save,
RotateCcw,
Keyboard,
Search,
} from "lucide-react";
import {
SiJavascript,
@@ -49,7 +50,7 @@ import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search } from "@codemirror/search";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands";
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
import { PhotoProvider, PhotoView } from "react-photo-view";
@@ -323,6 +324,7 @@ export function FileViewer({
const [pdfScale, setPdfScale] = useState(1.2);
const [pdfError, setPdfError] = useState(false);
const [markdownEditMode, setMarkdownEditMode] = useState(false);
const editorRef = useRef<any>(null);
const fileTypeInfo = getFileType(file.name);
@@ -348,7 +350,8 @@ export function FileViewer({
if (savedContent) {
setOriginalContent(savedContent);
}
setHasChanges(content !== (savedContent || content));
// Fix: Compare current content with saved content properly
setHasChanges(content !== savedContent);
// If unknown file type and file is large, show warning
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
@@ -361,7 +364,8 @@ export function FileViewer({
// Handle content changes
const handleContentChange = (newContent: string) => {
setEditedContent(newContent);
setHasChanges(newContent !== originalContent);
// Fix: Compare with savedContent instead of originalContent for consistency
setHasChanges(newContent !== savedContent);
onContentChange?.(newContent);
};
@@ -373,9 +377,9 @@ export function FileViewer({
// Revert file
const handleRevert = () => {
setEditedContent(originalContent);
setEditedContent(savedContent);
setHasChanges(false);
onContentChange?.(originalContent);
onContentChange?.(savedContent);
};
// Handle save shortcut specifically
@@ -453,6 +457,26 @@ export function FileViewer({
</div>
<div className="flex items-center gap-2">
{/* Search button */}
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => {
// Use CodeMirror's proper API to open search panel
if (editorRef.current) {
const view = editorRef.current.view;
if (view) {
openSearchPanel(view);
}
}
}}
className="flex items-center gap-2"
title="Search in file (Ctrl+F)"
>
<Search className="w-4 h-4" />
</Button>
)}
{/* Keyboard shortcuts help */}
{isEditable && (
<Button
@@ -557,14 +581,13 @@ export function FileViewer({
<span>{t("fileManager.redo")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">{t("fileManager.navigation")}</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.goToLine")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+G</kbd>
<span>{t("fileManager.toggleComment")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.autoComplete")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineUp")}</span>
@@ -576,27 +599,6 @@ export function FileViewer({
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">{t("fileManager.code")}</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.toggleComment")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.indent")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Tab</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.outdent")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+Tab</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.autoComplete")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
</div>
</div>
</div>
</div>
</div>
)}
@@ -737,6 +739,7 @@ export function FileViewer({
{isEditable ? (
// Unified CodeMirror editor for all text-based files
<CodeMirror
ref={editorRef}
value={editedContent}
onChange={(value) => handleContentChange(value)}
onFocus={() => setEditorFocused(true)}
@@ -906,17 +909,7 @@ export function FileViewer({
</Button>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<Button
variant="default"
size="sm"
onClick={() => onSave?.(editedContent)}
disabled={!hasChanges}
>
<Save className="w-4 h-4 mr-1" />
{t("fileManager.save")}
</Button>
)}
{/* Save button removed - using the main header save button instead */}
</div>
</div>
</div>

View File

@@ -525,13 +525,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const sampleData = {
hosts: [
{
name: "Web Server - Production",
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
@@ -540,7 +540,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
defaultPath: "/var/www",
},
{
name: "Database Server",
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
@@ -548,7 +548,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
@@ -558,7 +558,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: "Web Server - Production",
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
@@ -566,13 +566,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
],
},
{
name: "Development Server",
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
folder: "Development",
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
enableTerminal: true,
@@ -686,13 +686,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const sampleData = {
hosts: [
{
name: "Web Server - Production",
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
@@ -701,7 +701,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
defaultPath: "/var/www",
},
{
name: "Database Server",
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
@@ -709,7 +709,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
@@ -719,7 +719,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: "Web Server - Production",
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
@@ -727,13 +727,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
],
},
{
name: "Development Server",
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
folder: "Development",
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
enableTerminal: true,

View File

@@ -40,6 +40,7 @@ export function Server({
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
React.useEffect(() => {
setCurrentHostConfig(hostConfig);
@@ -116,10 +117,12 @@ export function Server({
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
setShowStatsUI(true);
}
} catch (error) {
if (!cancelled) {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.failedToFetchMetrics"));
}
} finally {
@@ -208,6 +211,7 @@ export function Server({
currentHostConfig.id,
);
setMetrics(data);
setShowStatsUI(true);
} catch (error: any) {
if (error?.response?.status === 503) {
setServerStatus("offline");
@@ -219,6 +223,7 @@ export function Server({
setServerStatus("offline");
}
setMetrics(null);
setShowStatsUI(false);
} finally {
setIsRefreshing(false);
}
@@ -267,7 +272,8 @@ export function Server({
<Separator className="p-0.25 w-full" />
{/* Stats */}
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
@@ -443,7 +449,8 @@ export function Server({
</div>
</div>
)}
</div>
</div>
)}
{/* SSH Tunnels */}
{currentHostConfig?.tunnelConnections &&

View File

@@ -304,18 +304,18 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082`
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
: isElectron()
? (() => {
const baseUrl =
(window as any).configuredServerUrl || "http://127.0.0.1:8081";
(window as any).configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/`;
return `${wsProtocol}${wsHost.replace(':30001', ':30002')}/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:8082/`;
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:30002/`;
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {

View File

@@ -146,7 +146,7 @@ export function ServerConfig({
<Input
id="server-url"
type="text"
placeholder="http://localhost:8081 or https://your-server.com"
placeholder="http://localhost:30001 or https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="flex-1 h-10"

View File

@@ -27,13 +27,11 @@ export function HomepageAlertManager({
}, [loggedIn, userId]);
const fetchUserAlerts = async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const response = await getUserAlerts(userId);
const response = await getUserAlerts();
const userAlerts = response.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
@@ -64,10 +62,8 @@ export function HomepageAlertManager({
};
const handleDismissAlert = async (alertId: string) => {
if (!userId) return;
try {
await dismissAlert(userId, alertId);
await dismissAlert(alertId);
setAlerts((prev) => {
const newAlerts = prev.filter((alert) => alert.id !== alertId);

View File

@@ -4,9 +4,9 @@ import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
import { toast } from "sonner";
import {
registerUser,
loginUser,
@@ -124,20 +124,32 @@ export function HomepageAuth({
}, []);
useEffect(() => {
setDbHealthChecking(true);
getSetupRequired()
.then((res) => {
if (res.setup_required) {
setFirstUser(true);
setTab("signup");
toast.info(t("auth.firstUserMessage"));
} else {
setFirstUser(false);
}
setDbError(null);
setDbConnectionFailed(false);
})
.catch(() => {
setDbError(t("errors.databaseConnection"));
setDbConnectionFailed(true);
})
.finally(() => {
setDbHealthChecking(false);
});
}, [setDbError]);
}, [setDbError, t]);
useEffect(() => {
if (!registrationAllowed && !internalLoggedIn) {
toast.warning(t("messages.registrationDisabled"));
}
}, [registrationAllowed, internalLoggedIn, t]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -145,7 +157,7 @@ export function HomepageAuth({
setLoading(true);
if (!localUsername.trim()) {
setError(t("errors.requiredField"));
toast.error(t("errors.requiredField"));
setLoading(false);
return;
}
@@ -156,12 +168,12 @@ export function HomepageAuth({
res = await loginUser(localUsername, password);
} else {
if (password !== signupConfirmPassword) {
setError(t("errors.passwordMismatch"));
toast.error(t("errors.passwordMismatch"));
setLoading(false);
return;
}
if (password.length < 6) {
setError(t("errors.minLength", { min: 6 }));
toast.error(t("errors.minLength", { min: 6 }));
setLoading(false);
return;
}
@@ -199,22 +211,25 @@ export function HomepageAuth({
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.id || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.id || null,
userId: meRes.userId || null,
});
setInternalLoggedIn(true);
if (tab === "signup") {
setSignupConfirmPassword("");
toast.success(t("messages.registrationSuccess"));
} else {
toast.success(t("messages.loginSuccess"));
}
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(
toast.error(
err?.response?.data?.error || err?.message || t("errors.unknownError"),
);
setInternalLoggedIn(false);
@@ -224,7 +239,7 @@ export function HomepageAuth({
setUserId(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError(t("errors.databaseConnection"));
setDbConnectionFailed(true);
} else {
setDbError(null);
}
@@ -239,9 +254,9 @@ export function HomepageAuth({
try {
const result = await initiatePasswordReset(localUsername);
setResetStep("verify");
setError(null);
toast.success(t("messages.resetCodeSent"));
} catch (err: any) {
setError(
toast.error(
err?.response?.data?.error ||
err?.message ||
t("errors.failedPasswordReset"),
@@ -258,9 +273,9 @@ export function HomepageAuth({
const response = await verifyPasswordResetCode(localUsername, resetCode);
setTempToken(response.tempToken);
setResetStep("newPassword");
setError(null);
toast.success(t("messages.codeVerified"));
} catch (err: any) {
setError(err?.response?.data?.error || t("errors.failedVerifyCode"));
toast.error(err?.response?.data?.error || t("errors.failedVerifyCode"));
} finally {
setResetLoading(false);
}
@@ -271,13 +286,13 @@ export function HomepageAuth({
setResetLoading(true);
if (newPassword !== confirmPassword) {
setError(t("errors.passwordMismatch"));
toast.error(t("errors.passwordMismatch"));
setResetLoading(false);
return;
}
if (newPassword.length < 6) {
setError(t("errors.minLength", { min: 6 }));
toast.error(t("errors.minLength", { min: 6 }));
setResetLoading(false);
return;
}
@@ -293,8 +308,9 @@ export function HomepageAuth({
setError(null);
setResetSuccess(true);
toast.success(t("messages.passwordResetSuccess"));
} catch (err: any) {
setError(err?.response?.data?.error || t("errors.failedCompleteReset"));
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
} finally {
setResetLoading(false);
}
@@ -319,7 +335,7 @@ export function HomepageAuth({
async function handleTOTPVerification() {
if (totpCode.length !== 6) {
setError(t("auth.enterCode"));
toast.error(t("auth.enterCode"));
return;
}
@@ -340,19 +356,20 @@ export function HomepageAuth({
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.id || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.id || null,
userId: meRes.userId || null,
});
setInternalLoggedIn(true);
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
toast.success(t("messages.loginSuccess"));
} catch (err: any) {
setError(
toast.error(
err?.response?.data?.error ||
err?.message ||
t("errors.invalidTotpCode"),
@@ -375,7 +392,7 @@ export function HomepageAuth({
window.location.replace(authUrl);
} catch (err: any) {
setError(
toast.error(
err?.response?.data?.error ||
err?.message ||
t("errors.failedOidcLogin"),
@@ -391,7 +408,7 @@ export function HomepageAuth({
const error = urlParams.get("error");
if (error) {
setError(`${t("errors.oidcAuthFailed")}: ${error}`);
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname);
return;
@@ -408,12 +425,12 @@ export function HomepageAuth({
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.id || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.id || null,
userId: meRes.userId || null,
});
setInternalLoggedIn(true);
window.history.replaceState(
@@ -423,7 +440,7 @@ export function HomepageAuth({
);
})
.catch((err) => {
setError(t("errors.failedUserInfo"));
toast.error(t("errors.failedUserInfo"));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
@@ -468,6 +485,34 @@ export function HomepageAuth({
null,
);
const [currentServerUrl, setCurrentServerUrl] = useState<string>("");
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const [dbHealthChecking, setDbHealthChecking] = useState(false);
useEffect(() => {
if (dbConnectionFailed) {
toast.error(t("errors.databaseConnection"));
}
}, [dbConnectionFailed, t]);
const retryDatabaseConnection = async () => {
setDbHealthChecking(true);
setDbConnectionFailed(false);
try {
const res = await getSetupRequired();
if (res.setup_required) {
setFirstUser(true);
setTab("signup");
} else {
setFirstUser(false);
}
setDbError(null);
toast.success(t("messages.databaseConnected"));
} catch (error) {
setDbConnectionFailed(true);
} finally {
setDbHealthChecking(false);
}
};
useEffect(() => {
const checkServerConfig = async () => {
@@ -519,42 +564,91 @@ export function HomepageAuth({
);
}
if (dbHealthChecking && !dbConnectionFailed) {
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
{...props}
>
<div className="flex items-center justify-center h-32">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground">
{t("common.checkingDatabase")}
</p>
</div>
</div>
</div>
);
}
if (dbConnectionFailed) {
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
{...props}
>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{t("errors.databaseConnection")}
</h2>
<p className="text-muted-foreground">
{t("messages.databaseConnectionFailed")}
</p>
</div>
<div className="flex flex-col gap-4">
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={dbHealthChecking}
onClick={() => window.location.reload()}
>
{t("common.refresh")}
</Button>
</div>
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
{t("common.language")}
</Label>
</div>
<LanguageSwitcher />
</div>
{isElectron() && currentServerUrl && (
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">
Server
</Label>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{currentServerUrl}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowServerConfig(true)}
className="h-8 px-3"
>
Edit
</Button>
</div>
)}
</div>
</div>
);
}
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
{...props}
>
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>{t("auth.firstUser")}</AlertTitle>
<AlertDescription className="inline">
{t("auth.firstUserMessage")}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline"
>
GitHub Issue
</a>
.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t("auth.registerTitle")}</AlertTitle>
<AlertDescription>
{t("messages.registrationDisabled")}
</AlertDescription>
</Alert>
)}
{totpRequired && (
<div className="flex flex-col gap-5">
<div className="mb-6 text-center">
@@ -805,14 +899,11 @@ export function HomepageAuth({
{resetSuccess && (
<>
<Alert className="mb-4">
<AlertTitle>
{t("auth.passwordResetSuccess")}
</AlertTitle>
<AlertDescription>
<div className="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/20 mb-4">
<p className="text-green-400 text-sm">
{t("auth.passwordResetSuccessDesc")}
</AlertDescription>
</Alert>
</p>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
@@ -998,12 +1089,6 @@ export function HomepageAuth({
</div>
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -361,8 +361,8 @@ export function LeftSidebar({
</div>
{hostsError && (
<div className="px-1">
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
<div className="!bg-dark-bg-input rounded-lg">
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
{t("leftSidebar.failedToLoadHosts")}
</div>
</div>

View File

@@ -311,17 +311,17 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082`
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
: isElectron()
? (() => {
const baseUrl =
(window as any).configuredServerUrl ||
"http://127.0.0.1:8081";
"http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/ssh/websocket/`;
return `${wsProtocol}${wsHost.replace(':30001', ':30002')}/ssh/websocket/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;

View File

@@ -283,7 +283,7 @@ function createApiInstance(
// Handle DEK (Data Encryption Key) invalidation
if (status === 423) {
const errorData = error.response?.data;
if (errorData?.error === "DATA_LOCKED" || errorData?.message?.includes("DATA_LOCKED")) {
if ((errorData as any)?.error === "DATA_LOCKED" || (errorData as any)?.message?.includes("DATA_LOCKED")) {
// DEK session has expired (likely due to server restart or timeout)
// Force logout to require re-authentication and DEK unlock
if (isElectron()) {
@@ -324,11 +324,11 @@ function isDev(): boolean {
}
let apiHost = import.meta.env.VITE_API_HOST || "localhost";
let apiPort = 8081;
let apiPort = 30001;
let configuredServerUrl: string | null = null;
if (isElectron()) {
apiPort = 8081;
apiPort = 30001;
}
export interface ServerConfig {
@@ -416,38 +416,38 @@ function getApiUrl(path: string, defaultPort: number): string {
// Initialize API instances
function initializeApiInstances() {
// SSH Host Management API (port 8081)
sshHostApi = createApiInstance(getApiUrl("/ssh", 8081), "SSH_HOST");
// SSH Host Management API (port 30001)
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
// Tunnel Management API (port 8083)
tunnelApi = createApiInstance(getApiUrl("/ssh", 8083), "TUNNEL");
// Tunnel Management API (port 30003)
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
// File Manager Operations API (port 8084)
// File Manager Operations API (port 30004)
fileManagerApi = createApiInstance(
getApiUrl("/ssh/file_manager", 8084),
getApiUrl("/ssh/file_manager", 30004),
"FILE_MANAGER",
);
// Server Statistics API (port 8085)
statsApi = createApiInstance(getApiUrl("", 8085), "STATS");
// Server Statistics API (port 30005)
statsApi = createApiInstance(getApiUrl("", 30005), "STATS");
// Authentication API (port 8081)
authApi = createApiInstance(getApiUrl("", 8081), "AUTH");
// Authentication API (port 30001)
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
}
// SSH Host Management API (port 8081)
// SSH Host Management API (port 30001)
export let sshHostApi: AxiosInstance;
// Tunnel Management API (port 8083)
// Tunnel Management API (port 30003)
export let tunnelApi: AxiosInstance;
// File Manager Operations API (port 8084)
// File Manager Operations API (port 30004)
export let fileManagerApi: AxiosInstance;
// Server Statistics API (port 8085)
// Server Statistics API (port 30005)
export let statsApi: AxiosInstance;
// Authentication API (port 8081)
// Authentication API (port 30001)
export let authApi: AxiosInstance;
// Initialize API instances immediately
@@ -1763,11 +1763,9 @@ export async function generateBackupCodes(
}
}
export async function getUserAlerts(
userId: string,
): Promise<{ alerts: any[] }> {
export async function getUserAlerts(): Promise<{ alerts: any[] }> {
try {
const response = await authApi.get(`/alerts/user/${userId}`);
const response = await authApi.get(`/alerts`);
return response.data;
} catch (error) {
handleApiError(error, "fetch user alerts");
@@ -1775,11 +1773,10 @@ export async function getUserAlerts(
}
export async function dismissAlert(
userId: string,
alertId: string,
): Promise<any> {
try {
const response = await authApi.post("/alerts/dismiss", { userId, alertId });
const response = await authApi.post("/alerts/dismiss", { alertId });
return response.data;
} catch (error) {
handleApiError(error, "dismiss alert");