diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index ce7bb04b..1c4b2e73 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -37,7 +37,7 @@ "loadingCredentials": "Loading credentials...", "retry": "Retry", "noCredentials": "No Credentials", - "noCredentialsMessage": "Start by creating your first SSH credential", + "noCredentialsMessage": "You haven't added any credentials yet. Click \"Add Credential\" to get started.", "sshCredentials": "SSH Credentials", "credentialsCount": "{{count}} credentials", "refresh": "Refresh", @@ -123,7 +123,14 @@ "editCredentialDescription": "Update the credential information", "listView": "List", "folderView": "Folders", - "unknown": "Unknown" + "unknown": "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", + "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", + "failedToRenameFolder": "Failed to rename folder", + "movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully", + "failedToMoveToFolder": "Failed to move credential to folder" }, "sshTools": { "title": "SSH Tools", @@ -310,6 +317,8 @@ "allowNewAccountRegistration": "Allow new account registration", "missingRequiredFields": "Missing required fields: {{fields}}", "oidcConfigurationUpdated": "OIDC configuration updated successfully!", + "failedToFetchOidcConfig": "Failed to fetch OIDC configuration", + "failedToFetchRegistrationStatus": "Failed to fetch registration status", "oidcConfigurationDisabled": "OIDC configuration disabled successfully!", "failedToUpdateOidcConfig": "Failed to update OIDC configuration", "failedToDisableOidcConfig": "Failed to disable OIDC configuration", @@ -340,6 +349,8 @@ "importJsonDesc": "Upload a JSON file to bulk import multiple SSH hosts (max 100).", "downloadSample": "Download Sample", "formatGuide": "Format Guide", + "exportCredentialWarning": "Warning: Host \"{{name}}\" uses credential authentication. The exported file will not include the credential data and will need to be manually reconfigured after import. Do you want to continue?", + "exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will not include this data for security reasons. You'll need to reconfigure authentication after import. Do you want to continue?", "uncategorized": "Uncategorized", "confirmDelete": "Are you sure you want to delete \"{{name}}\" ?", "failedToDeleteHost": "Failed to delete host", @@ -413,6 +424,7 @@ "selectCredential": "Select Credential", "selectCredentialPlaceholder": "Choose a credential...", "credentialRequired": "Credential is required when using credential authentication", + "credentialDescription": "Selecting a credential will overwrite the current username and use the credential's authentication details.", "sshPrivateKey": "SSH Private Key", "keyPassword": "Key Password", "keyType": "Key Type", @@ -428,6 +440,8 @@ "uploadFile": "Upload File", "pasteKey": "Paste Key", "updateKey": "Update Key", + "existingKey": "Existing Key (click to change)", + "existingCredential": "Existing Credential (click to change)", "addTagsSpaceToAdd": "add tags (space to add)", "terminalBadge": "Terminal", "tunnelBadge": "Tunnel", @@ -460,10 +474,18 @@ "reconnect": "Reconnect", "sessionEnded": "Session Ended", "connectionLost": "Connection Lost", - "error": "ERROR", + "error": "ERROR: {{message}}", "disconnected": "Disconnected", "connectionClosed": "Connection closed", - "connectionError": "Connection error" + "connectionError": "Connection error: {{message}}", + "connected": "Connected", + "sshConnected": "SSH connection established", + "authError": "Authentication failed: {{message}}", + "unknownError": "Unknown error occurred", + "messageParseError": "Failed to parse server message", + "websocketError": "WebSocket connection error", + "reconnecting": "Reconnecting... ({{attempt}}/{{max}})", + "maxReconnectAttemptsReached": "Maximum reconnection attempts reached" }, "fileManager": { "title": "File Manager", @@ -483,6 +505,11 @@ "clickToSelectFile": "Click to select a file", "chooseFile": "Choose File", "uploading": "Uploading...", + "uploadingFile": "Uploading {{name}}...", + "creatingFile": "Creating {{name}}...", + "creatingFolder": "Creating {{name}}...", + "deletingItem": "Deleting {{type}} {{name}}...", + "renamingItem": "Renaming {{type}} {{oldName}} to {{newName}}...", "createNewFile": "Create New File", "fileName": "File Name", "creating": "Creating...", @@ -652,7 +679,10 @@ "memoryUsage": "Memory Usage", "rootStorageSpace": "Root Storage Space", "of": "of", - "feedbackMessage": "Have ideas for what should come next for server management? Share them on" + "feedbackMessage": "Have ideas for what should come next for server management? Share them on", + "failedToFetchHostConfig": "Failed to fetch host configuration", + "failedToFetchStatus": "Failed to fetch server status", + "failedToFetchMetrics": "Failed to fetch server metrics" }, "auth": { "loginTitle": "Login to Termix", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 427a8935..109467f1 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -37,7 +37,7 @@ "loadingCredentials": "正在加载凭据...", "retry": "重试", "noCredentials": "暂无凭据", - "noCredentialsMessage": "开始创建您的第一个SSH凭据", + "noCredentialsMessage": "你还没有添加任何凭证。点击“添加凭证”以开始。", "sshCredentials": "SSH凭据", "credentialsCount": "{{count}} 个凭据", "refresh": "刷新", @@ -123,7 +123,14 @@ "editCredentialDescription": "更新凭据信息", "listView": "列表", "folderView": "文件夹", - "unknown": "未知" + "unknown": "未知", + "confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?凭据将被移动到\"未分类\"。", + "removedFromFolder": "凭据\"{{name}}\"已成功从文件夹中移除", + "failedToRemoveFromFolder": "从文件夹中移除凭据失败", + "folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"", + "failedToRenameFolder": "重命名文件夹失败", + "movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"", + "failedToMoveToFolder": "移动凭据到文件夹失败" }, "sshTools": { "title": "SSH 工具", @@ -339,6 +346,8 @@ "importJsonDesc": "上传 JSON 文件以批量导入多个 SSH 主机(最多 100 个)。", "downloadSample": "下载示例", "formatGuide": "格式指南", + "exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?", + "exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?", "uncategorized": "未分类", "confirmDelete": "确定要删除 \"{{name}}\" 吗?", "failedToDeleteHost": "删除主机失败", @@ -433,6 +442,7 @@ "selectCredential": "选择凭证", "selectCredentialPlaceholder": "选择一个凭证...", "credentialRequired": "使用凭证认证时需要选择凭证", + "credentialDescription": "选择凭证将覆盖当前用户名并使用凭证的认证详细信息。", "sshPrivateKey": "SSH 私钥", "keyPassword": "密钥密码", "keyType": "密钥类型", @@ -466,6 +476,8 @@ "uploadFile": "上传文件", "pasteKey": "粘贴密钥", "updateKey": "更新密钥", + "existingKey": "现有密钥(点击更改)", + "existingCredential": "现有凭据(点击更改)", "addTagsSpaceToAdd": "添加标签(空格添加)", "terminalBadge": "终端", "tunnelBadge": "隧道", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 9b026f8a..b07ec3cd 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -35,7 +35,6 @@ class GitHubCache { timestamp: now, expiresAt: now + this.CACHE_DURATION }); - // Cache entry set } get(key: string): any | null { diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts index 8927c629..c23c57ed 100644 --- a/src/backend/database/routes/alerts.ts +++ b/src/backend/database/routes/alerts.ts @@ -177,7 +177,6 @@ router.post('/dismiss', async (req, res) => { alertId }); - authLogger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`); res.json({message: 'Alert dismissed successfully'}); } catch (error) { authLogger.error('Failed to dismiss alert', error); @@ -233,8 +232,6 @@ router.delete('/dismiss', async (req, res) => { if (result.changes === 0) { return res.status(404).json({error: 'Dismissed alert not found'}); } - - authLogger.success(`Alert ${alertId} undismissed by user ${userId}`); res.json({message: 'Alert undismissed successfully'}); } catch (error) { authLogger.error('Failed to undismiss alert', error); diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 164dd11a..f68e9aa5 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -54,8 +54,6 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => { keyType } = req.body; - authLogger.info('Credential creation request received', { operation: 'credential_create', userId, name, authType, username }); - if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) { authLogger.warn('Invalid credential creation data validation failed', { operation: 'credential_create', userId, hasName: !!name, hasUsername: !!username }); return res.status(400).json({error: 'Name and username are required'}); @@ -75,8 +73,6 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => { authLogger.warn('SSH key required for key authentication', { operation: 'credential_create', userId, name, authType }); return res.status(400).json({error: 'SSH key is required for key authentication'}); } - - authLogger.info('Preparing credential data for database insertion', { operation: 'credential_create', userId, name, authType, hasPassword: !!password, hasKey: !!key }); const plainPassword = (authType === 'password' && password) ? password : null; const plainKey = (authType === 'key' && key) ? key : null; const plainKeyPassword = (authType === 'key' && keyPassword) ? keyPassword : null; @@ -97,10 +93,18 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => { lastUsed: null, }; - authLogger.info('Inserting credential into database', { operation: 'credential_create', userId, name, authType, username }); const result = await db.insert(sshCredentials).values(credentialData).returning(); const created = result[0]; - authLogger.success('Credential created successfully', { operation: 'credential_create', userId, name, credentialId: created.id, authType, username }); + + authLogger.success(`SSH credential created: ${name} (${authType}) by user ${userId}`, { + operation: 'credential_create_success', + userId, + credentialId: created.id, + name, + authType, + username + }); + res.status(201).json(formatCredentialOutput(created)); } catch (err) { authLogger.error('Failed to create credential in database', err, { operation: 'credential_create', userId, name, authType, username }); @@ -280,6 +284,16 @@ router.put('/:id', authenticateJWT, async (req: Request, res: Response) => { .from(sshCredentials) .where(eq(sshCredentials.id, parseInt(id))); + const credential = updated[0]; + authLogger.success(`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, { + operation: 'credential_update_success', + userId, + credentialId: parseInt(id), + name: credential.name, + authType: credential.authType, + username: credential.username + }); + res.json(formatCredentialOutput(updated[0])); } catch (err) { authLogger.error('Failed to update credential', err); @@ -301,6 +315,18 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => { } try { + const credentialToDelete = await db + .select() + .from(sshCredentials) + .where(and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId) + )); + + if (credentialToDelete.length === 0) { + return res.status(404).json({error: 'Credential not found'}); + } + const hostsUsingCredential = await db .select() .from(sshData) @@ -339,6 +365,16 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => { eq(sshCredentials.userId, userId) )); + const credential = credentialToDelete[0]; + authLogger.success(`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`, { + operation: 'credential_delete_success', + userId, + credentialId: parseInt(id), + name: credential.name, + authType: credential.authType, + username: credential.username + }); + res.json({message: 'Credential deleted successfully'}); } catch (err) { authLogger.error('Failed to delete credential', err); @@ -489,4 +525,33 @@ function formatSSHHostOutput(host: any): any { }; } +// Rename a credential folder +// PUT /credentials/folders/rename +router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { oldName, newName } = req.body; + + if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) { + return res.status(400).json({ error: 'Both oldName and newName are required' }); + } + + if (oldName === newName) { + return res.status(400).json({ error: 'Old name and new name cannot be the same' }); + } + + try { + await db.update(sshCredentials) + .set({ folder: newName }) + .where(and( + eq(sshCredentials.userId, userId), + eq(sshCredentials.folder, oldName) + )); + + res.json({ success: true, message: 'Folder renamed successfully' }); + } catch (error) { + authLogger.error('Error renaming credential folder:', error); + res.status(500).json({ error: 'Failed to rename folder' }); + } +}); + export default router; \ No newline at end of file diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index d3b43a92..6c262510 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -182,6 +182,16 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque const resolvedHost = await resolveHostCredentials(baseHost) || baseHost; + sshLogger.success(`SSH host created: ${name} (${ip}:${port}) by user ${userId}`, { + operation: 'host_create_success', + userId, + hostId: createdHost.id, + name, + ip, + port, + authType: effectiveAuthType + }); + res.json(resolvedHost); } catch (err) { sshLogger.error('Failed to save SSH host to database', err, { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType }); @@ -315,6 +325,16 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re const resolvedHost = await resolveHostCredentials(baseHost) || baseHost; + sshLogger.success(`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, { + operation: 'host_update_success', + userId, + hostId: parseInt(hostId), + name, + ip, + port, + authType: effectiveAuthType + }); + res.json(resolvedHost); } catch (err) { sshLogger.error('Failed to update SSH host in database', err, { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType }); @@ -407,8 +427,29 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons return res.status(400).json({error: 'Invalid userId or id'}); } try { + const hostToDelete = await db + .select() + .from(sshData) + .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + + if (hostToDelete.length === 0) { + sshLogger.warn('SSH host not found for deletion', { operation: 'host_delete', hostId: parseInt(hostId), userId }); + return res.status(404).json({error: 'SSH host not found'}); + } + const result = await db.delete(sshData) .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + + const host = hostToDelete[0]; + sshLogger.success(`SSH host deleted: ${host.name} (${host.ip}:${host.port}) by user ${userId}`, { + operation: 'host_delete_success', + userId, + hostId: parseInt(hostId), + name: host.name, + ip: host.ip, + port: host.port + }); + res.json({message: 'SSH host deleted'}); } catch (err) { sshLogger.error('Failed to delete SSH host from database', err, { operation: 'host_delete', hostId: parseInt(hostId), userId }); @@ -777,15 +818,6 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons )) .returning(); - sshLogger.success('Folder renamed successfully', { - operation: 'folder_rename', - userId, - oldName, - newName, - updatedHosts: updatedHosts.length, - updatedCredentials: updatedCredentials.length - }); - res.json({ message: 'Folder renamed successfully', updatedHosts: updatedHosts.length, @@ -797,4 +829,103 @@ router.put('/folders/rename', authenticateJWT, async (req: Request, res: Respons } }); +// Route: Bulk import SSH hosts (requires JWT) +// POST /ssh/bulk-import +router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { hosts } = req.body; + + if (!Array.isArray(hosts) || hosts.length === 0) { + return res.status(400).json({ error: 'Hosts array is required and must not be empty' }); + } + + if (hosts.length > 100) { + return res.status(400).json({ error: 'Maximum 100 hosts allowed per import' }); + } + + const results = { + success: 0, + failed: 0, + errors: [] as string[] + }; + + for (let i = 0; i < hosts.length; i++) { + const hostData = hosts[i]; + + try { + // Validate required fields + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Missing required fields (ip, port, username)`); + continue; + } + + // Validate authType + if (!['password', 'key', 'credential'].includes(hostData.authType)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`); + continue; + } + + // Validate authentication data based on authType + if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Password required for password authentication`); + continue; + } + + if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Key required for key authentication`); + continue; + } + + if (hostData.authType === 'credential' && !hostData.credentialId) { + results.failed++; + results.errors.push(`Host ${i + 1}: credentialId required for credential authentication`); + continue; + } + + // Prepare host data for insertion + const sshDataObj: any = { + userId: userId, + name: hostData.name || `${hostData.username}@${hostData.ip}`, + folder: hostData.folder || 'Default', + tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : '', + ip: hostData.ip, + port: hostData.port, + username: hostData.username, + password: hostData.authType === 'password' ? hostData.password : null, + authType: hostData.authType, + credentialId: hostData.authType === 'credential' ? hostData.credentialId : null, + key: hostData.authType === 'key' ? hostData.key : null, + keyPassword: hostData.authType === 'key' ? hostData.keyPassword : null, + keyType: hostData.authType === 'key' ? (hostData.keyType || 'auto') : null, + pin: hostData.pin || false, + enableTerminal: hostData.enableTerminal !== false, + enableTunnel: hostData.enableTunnel !== false, + enableFileManager: hostData.enableFileManager !== false, + defaultPath: hostData.defaultPath || '/', + tunnelConnections: hostData.tunnelConnections ? JSON.stringify(hostData.tunnelConnections) : '[]', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + await db.insert(sshData).values(sshDataObj); + results.success++; + + } catch (error) { + results.failed++; + results.errors.push(`Host ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + res.json({ + message: `Import completed: ${results.success} successful, ${results.failed} failed`, + success: results.success, + failed: results.failed, + errors: results.errors + }); +}); + export default router; \ No newline at end of file diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index a3bc54fa..c8874ab7 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -135,8 +135,9 @@ router.post('/create', async (req, res) => { if (row && (row as any).value !== 'true') { return res.status(403).json({error: 'Registration is currently disabled'}); } - } catch (e) { - } + } catch (e) { + authLogger.warn('Failed to check registration status', { operation: 'registration_check', error: e }); + } const {username, password} = req.body; @@ -159,19 +160,14 @@ router.post('/create', async (req, res) => { try { const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get(); isFirstUser = ((countResult as any)?.count || 0) === 0; - authLogger.info('Checked user count for admin status', { operation: 'user_create', username, isFirstUser }); } catch (e) { isFirstUser = true; authLogger.warn('Failed to check user count, assuming first user', { operation: 'user_create', username, error: e }); } - authLogger.info('Hashing password for new user', { operation: 'user_create', username, saltRounds: parseInt(process.env.SALT || '10', 10) }); const saltRounds = parseInt(process.env.SALT || '10', 10); const password_hash = await bcrypt.hash(password, saltRounds); const id = nanoid(); - authLogger.info('Generated user ID and hashed password', { operation: 'user_create', username, userId: id }); - - authLogger.info('Inserting new user into database', { operation: 'user_create', username, userId: id, isAdmin: isFirstUser }); await db.insert(users).values({ id, username, @@ -192,7 +188,7 @@ router.post('/create', async (req, res) => { }); authLogger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`, { operation: 'user_create', username, isAdmin: isFirstUser, userId: id }); - res.json({message: 'User created', is_admin: isFirstUser}); + res.json({message: 'User created', is_admin: isFirstUser, toast: {type: 'success', message: `User created: ${username}`}}); } catch (err) { authLogger.error('Failed to create user', err); res.status(500).json({error: 'Failed to create user'}); @@ -220,27 +216,6 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { name_path, scopes } = req.body; - - authLogger.info('OIDC config update request received', { - operation: 'oidc_config_update', - userId, - hasClientId: !!client_id, - hasClientSecret: !!client_secret, - hasIssuerUrl: !!issuer_url, - hasAuthUrl: !!authorization_url, - hasTokenUrl: !!token_url, - hasIdentifierPath: !!identifier_path, - hasNamePath: !!name_path, - clientIdValue: `"${client_id}"`, - clientSecretValue: client_secret ? '[REDACTED]' : `"${client_secret}"`, - issuerUrlValue: `"${issuer_url}"`, - authUrlValue: `"${authorization_url}"`, - tokenUrlValue: `"${token_url}"`, - identifierPathValue: `"${identifier_path}"`, - namePathValue: `"${name_path}"`, - scopesValue: `"${scopes}"`, - userinfoUrlValue: `"${userinfo_url}"` - }); const isDisableRequest = (client_id === '' || client_id === null || client_id === undefined) && (client_secret === '' || client_secret === null || client_secret === undefined) && @@ -253,29 +228,6 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { isNonEmptyString(token_url) && isNonEmptyString(identifier_path) && isNonEmptyString(name_path); - authLogger.info('OIDC validation results', { - operation: 'oidc_config_update', - userId, - isDisableRequest, - isEnableRequest, - disableChecks: { - clientIdEmpty: client_id === '' || client_id === null || client_id === undefined, - clientSecretEmpty: client_secret === '' || client_secret === null || client_secret === undefined, - issuerUrlEmpty: issuer_url === '' || issuer_url === null || issuer_url === undefined, - authUrlEmpty: authorization_url === '' || authorization_url === null || authorization_url === undefined, - tokenUrlEmpty: token_url === '' || token_url === null || token_url === undefined - }, - enableChecks: { - clientIdPresent: isNonEmptyString(client_id), - clientSecretPresent: isNonEmptyString(client_secret), - issuerUrlPresent: isNonEmptyString(issuer_url), - authUrlPresent: isNonEmptyString(authorization_url), - tokenUrlPresent: isNonEmptyString(token_url), - identifierPathPresent: isNonEmptyString(identifier_path), - namePathPresent: isNonEmptyString(name_path) - } - }); - if (!isDisableRequest && !isEnableRequest) { authLogger.warn('OIDC validation failed - neither disable nor enable request', { operation: 'oidc_config_update', @@ -287,7 +239,6 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { } if (isDisableRequest) { - // Disable OIDC by removing the configuration db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); authLogger.info('OIDC configuration disabled', { operation: 'oidc_disable', userId }); res.json({message: 'OIDC configuration disabled'}); @@ -324,8 +275,6 @@ router.delete('/oidc-config', authenticateJWT, async (req, res) => { if (!user || user.length === 0 || !user[0].is_admin) { return res.status(403).json({error: 'Not authorized'}); } - - authLogger.info('OIDC disable request received', { operation: 'oidc_disable', userId }); db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); authLogger.success('OIDC configuration disabled', { operation: 'oidc_disable', userId }); @@ -480,7 +429,6 @@ router.get('/oidc/callback', async (req, res) => { if (tokenData.id_token) { try { userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id); - authLogger.info('Successfully verified ID token and extracted user info'); } catch (error) { authLogger.error('OIDC token verification failed, trying userinfo endpoints', error); try { @@ -488,7 +436,6 @@ router.get('/oidc/callback', async (req, res) => { if (parts.length === 3) { const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); userInfo = payload; - authLogger.info('Successfully decoded ID token payload without verification'); } } catch (decodeError) { authLogger.error('Failed to decode ID token payload:', decodeError); @@ -586,6 +533,8 @@ router.get('/oidc/callback', async (req, res) => { .select() .from(users) .where(eq(users.id, id)); + + // OIDC user created - toast notification handled by frontend } else { await db.update(users) .set({username: name}) @@ -595,6 +544,8 @@ router.get('/oidc/callback', async (req, res) => { .select() .from(users) .where(eq(users.id, user[0].id)); + + // OIDC user logged in - toast notification handled by frontend } const userRecord = user[0]; @@ -660,34 +611,29 @@ router.post('/login', async (req, res) => { return res.status(403).json({error: 'This user uses external authentication'}); } - authLogger.info('Verifying password for user login', { operation: 'user_login', username, userId: userRecord.id }); const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { authLogger.warn(`Incorrect password for user: ${username}`, { operation: 'user_login', username, userId: userRecord.id }); return res.status(401).json({error: 'Incorrect password'}); } - - authLogger.info('Password verified, generating JWT token', { operation: 'user_login', username, userId: userRecord.id, totpEnabled: userRecord.totp_enabled }); const jwtSecret = process.env.JWT_SECRET || 'secret'; const token = jwt.sign({userId: userRecord.id}, jwtSecret, { expiresIn: '50d', }); + // Traditional user logged in - toast notification handled by frontend + if (userRecord.totp_enabled) { - authLogger.info('User has TOTP enabled, requiring additional verification', { operation: 'user_login', username, userId: userRecord.id }); const tempToken = jwt.sign( {userId: userRecord.id, pending_totp: true}, jwtSecret, {expiresIn: '10m'} ); - authLogger.success('TOTP verification required for login', { operation: 'user_login', username, userId: userRecord.id }); return res.json({ requires_totp: true, temp_token: tempToken }); } - - authLogger.success('User login successful', { operation: 'user_login', username, userId: userRecord.id, isAdmin: !!userRecord.is_admin }); return res.json({ token, is_admin: !!userRecord.is_admin, @@ -1022,6 +968,7 @@ router.post('/make-admin', authenticateJWT, async (req, res) => { .where(eq(users.username, username)); authLogger.success(`User ${username} made admin by ${adminUser[0].username}`); + // User made admin - toast notification handled by frontend res.json({message: `User ${username} is now an admin`}); } catch (err) { @@ -1064,6 +1011,7 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => { .where(eq(users.username, username)); authLogger.success(`Admin status removed from ${username} by ${adminUser[0].username}`); + // Admin status removed - toast notification handled by frontend res.json({message: `Admin status removed from ${username}`}); } catch (err) { @@ -1125,6 +1073,8 @@ router.post('/totp/verify-login', async (req, res) => { expiresIn: '50d', }); + // TOTP login completed - toast notification handled by frontend + return res.json({ token, is_admin: !!userRecord.is_admin, @@ -1224,6 +1174,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => { }) .where(eq(users.id, userId)); + // 2FA enabled - toast notification handled by frontend res.json({ message: 'TOTP enabled successfully', backup_codes: backupCodes @@ -1285,6 +1236,7 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => { }) .where(eq(users.id, userId)); + // 2FA disabled - toast notification handled by frontend res.json({message: 'TOTP disabled successfully'}); } catch (err) { @@ -1401,6 +1353,7 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => { await db.delete(users).where(eq(users.id, targetUserId)); authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`); + // User deleted - toast notification handled by frontend res.json({message: `User ${username} deleted successfully`}); } catch (err) { diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 59be6bd6..0b11363a 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -307,8 +307,8 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => { }); }); -app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { - const {sessionId, path: filePath, content} = req.body; +app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => { + const {sessionId, path: filePath, content, hostId, userId} = req.body; const sshConn = sshSessions[sessionId]; if (!sessionId) { @@ -371,7 +371,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { if (hasError || hasFinished) return; hasFinished = true; if (!res.headersSent) { - res.json({message: 'File written successfully', path: filePath}); + res.json({message: 'File written successfully', path: filePath, toast: {type: 'success', message: `File written: ${filePath}`}}); } }); @@ -379,7 +379,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { if (hasError || hasFinished) return; hasFinished = true; if (!res.headersSent) { - res.json({message: 'File written successfully', path: filePath}); + res.json({message: 'File written successfully', path: filePath, toast: {type: 'success', message: `File written: ${filePath}`}}); } }); @@ -430,10 +430,10 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { stream.on('close', (code) => { - if (outputData.includes('SUCCESS')) { - if (!res.headersSent) { - res.json({message: 'File written successfully', path: filePath}); - } + if (outputData.includes('SUCCESS')) { + if (!res.headersSent) { + res.json({message: 'File written successfully', path: filePath, toast: {type: 'success', message: `File written: ${filePath}`}}); + } } else { fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`); if (!res.headersSent) { @@ -462,8 +462,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { trySFTP(); }); -app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { - const {sessionId, path: filePath, content, fileName} = req.body; +app.post('/ssh/file_manager/ssh/uploadFile', async (req, res) => { + const {sessionId, path: filePath, content, fileName, hostId, userId} = req.body; const sshConn = sshSessions[sessionId]; if (!sessionId) { @@ -527,7 +527,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { if (hasError || hasFinished) return; hasFinished = true; if (!res.headersSent) { - res.json({message: 'File uploaded successfully', path: fullPath}); + res.json({message: 'File uploaded successfully', path: fullPath, toast: {type: 'success', message: `File uploaded: ${fullPath}`}}); } }); @@ -535,7 +535,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { if (hasError || hasFinished) return; hasFinished = true; if (!res.headersSent) { - res.json({message: 'File uploaded successfully', path: fullPath}); + res.json({message: 'File uploaded successfully', path: fullPath, toast: {type: 'success', message: `File uploaded: ${fullPath}`}}); } }); @@ -597,9 +597,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { if (outputData.includes('SUCCESS')) { - fileLogger.success(`File uploaded successfully via fallback: ${fullPath}`); if (!res.headersSent) { - res.json({message: 'File uploaded successfully', path: fullPath}); + res.json({message: 'File uploaded successfully', path: fullPath, toast: {type: 'success', message: `File uploaded: ${fullPath}`}}); } } else { fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`); @@ -655,9 +654,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { if (outputData.includes('SUCCESS')) { - fileLogger.success(`File uploaded successfully via chunked fallback: ${fullPath}`); if (!res.headersSent) { - res.json({message: 'File uploaded successfully', path: fullPath}); + res.json({message: 'File uploaded successfully', path: fullPath, toast: {type: 'success', message: `File uploaded: ${fullPath}`}}); } } else { fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`); @@ -686,8 +684,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { trySFTP(); }); -app.post('/ssh/file_manager/ssh/createFile', (req, res) => { - const {sessionId, path: filePath, fileName, content = ''} = req.body; +app.post('/ssh/file_manager/ssh/createFile', async (req, res) => { + const {sessionId, path: filePath, fileName, content = '', hostId, userId} = req.body; const sshConn = sshSessions[sessionId]; if (!sessionId) { @@ -742,7 +740,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { stream.on('close', (code) => { if (outputData.includes('SUCCESS')) { if (!res.headersSent) { - res.json({message: 'File created successfully', path: fullPath}); + res.json({message: 'File created successfully', path: fullPath, toast: {type: 'success', message: `File created: ${fullPath}`}}); } return; } @@ -756,7 +754,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { } if (!res.headersSent) { - res.json({message: 'File created successfully', path: fullPath}); + res.json({message: 'File created successfully', path: fullPath, toast: {type: 'success', message: `File created: ${fullPath}`}}); } }); @@ -769,8 +767,8 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => { }); }); -app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { - const {sessionId, path: folderPath, folderName} = req.body; +app.post('/ssh/file_manager/ssh/createFolder', async (req, res) => { + const {sessionId, path: folderPath, folderName, hostId, userId} = req.body; const sshConn = sshSessions[sessionId]; if (!sessionId) { @@ -826,7 +824,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { stream.on('close', (code) => { if (outputData.includes('SUCCESS')) { if (!res.headersSent) { - res.json({message: 'Folder created successfully', path: fullPath}); + res.json({message: 'Folder created successfully', path: fullPath, toast: {type: 'success', message: `Folder created: ${fullPath}`}}); } return; } @@ -840,7 +838,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { } if (!res.headersSent) { - res.json({message: 'Folder created successfully', path: fullPath}); + res.json({message: 'Folder created successfully', path: fullPath, toast: {type: 'success', message: `Folder created: ${fullPath}`}}); } }); @@ -853,8 +851,8 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { }); }); -app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { - const {sessionId, path: itemPath, isDirectory} = req.body; +app.delete('/ssh/file_manager/ssh/deleteItem', async (req, res) => { + const {sessionId, path: itemPath, isDirectory, hostId, userId} = req.body; const sshConn = sshSessions[sessionId]; if (!sessionId) { @@ -909,7 +907,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { stream.on('close', (code) => { if (outputData.includes('SUCCESS')) { if (!res.headersSent) { - res.json({message: 'Item deleted successfully', path: itemPath}); + res.json({message: 'Item deleted successfully', path: itemPath, toast: {type: 'success', message: `${isDirectory ? 'Directory' : 'File'} deleted: ${itemPath}`}}); } return; } @@ -923,7 +921,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { } if (!res.headersSent) { - res.json({message: 'Item deleted successfully', path: itemPath}); + res.json({message: 'Item deleted successfully', path: itemPath, toast: {type: 'success', message: `${isDirectory ? 'Directory' : 'File'} deleted: ${itemPath}`}}); } }); @@ -936,8 +934,8 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { }); }); -app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { - const {sessionId, oldPath, newName} = req.body; +app.put('/ssh/file_manager/ssh/renameItem', async (req, res) => { + const {sessionId, oldPath, newName, hostId, userId} = req.body; const sshConn = sshSessions[sessionId]; if (!sessionId) { @@ -994,7 +992,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { stream.on('close', (code) => { if (outputData.includes('SUCCESS')) { if (!res.headersSent) { - res.json({message: 'Item renamed successfully', oldPath, newPath}); + res.json({message: 'Item renamed successfully', oldPath, newPath, toast: {type: 'success', message: `Item renamed: ${oldPath} -> ${newPath}`}}); } return; } @@ -1008,7 +1006,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { } if (!res.headersSent) { - res.json({message: 'Item renamed successfully', oldPath, newPath}); + res.json({message: 'Item renamed successfully', oldPath, newPath, toast: {type: 'success', message: `Item renamed: ${oldPath} -> ${newPath}`}}); } }); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 9ab2f454..de0be5c4 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -16,8 +16,6 @@ wss.on('connection', (ws: WebSocket) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; let pingInterval: NodeJS.Timeout | null = null; - - sshLogger.info('New WebSocket connection established', { operation: 'websocket_connect' }); ws.on('close', () => { @@ -40,7 +38,6 @@ wss.on('connection', (ws: WebSocket) => { switch (type) { case 'connectToHost': - sshLogger.info('SSH connection request received', { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip, port: data.hostConfig?.port }); handleConnectToHost(data).catch(error => { sshLogger.error('Failed to connect to host', error, { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip }); ws.send(JSON.stringify({type: 'error', message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')})); diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 967656b1..2bb4323f 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -382,12 +382,7 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P const tunnelName = tunnelConfig.name; const tunnelMarker = getTunnelMarker(tunnelName); - if (retryAttempt === 0) { - tunnelLogger.info('SSH tunnel connection attempt started', { operation: 'tunnel_connect', tunnelName, sourceIP: tunnelConfig.sourceIP, sourcePort: tunnelConfig.sourceSSHPort }); - } - if (manualDisconnects.has(tunnelName)) { - tunnelLogger.info('Tunnel connection cancelled due to manual disconnect', { operation: 'tunnel_connect', tunnelName }); return; } diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 8b2bd222..ec39cf0b 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -6,15 +6,23 @@ import './ssh/terminal.js'; import './ssh/tunnel.js'; import './ssh/file-manager.js'; import './ssh/server-stats.js'; -import { systemLogger } from './utils/logger.js'; +import { systemLogger, versionLogger } from './utils/logger.js'; +import 'dotenv/config'; (async () => { try { + const version = process.env.VERSION || 'unknown'; + versionLogger.info(`Termix Backend starting - Version: ${version}`, { + operation: 'startup', + version: version + }); + systemLogger.info("Initializing backend services...", { operation: 'startup' }); systemLogger.success("All backend services initialized successfully", { operation: 'startup_complete', - services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'] + services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'], + version: version }); process.on('SIGINT', () => { diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index b59b1e6f..73ef9992 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -158,13 +158,14 @@ class Logger { } } -export const databaseLogger = new Logger('DATABASE', '🗄️', '#1e3a8a'); -export const sshLogger = new Logger('SSH', '🖥️', '#1e3a8a'); -export const tunnelLogger = new Logger('TUNNEL', '📡', '#1e3a8a'); -export const fileLogger = new Logger('FILE', '📁', '#1e3a8a'); +export const databaseLogger = new Logger('DATABASE', '🗄️', '#6366f1'); +export const sshLogger = new Logger('SSH', '🖥️', '#0ea5e9'); +export const tunnelLogger = new Logger('TUNNEL', '📡', '#a855f7'); +export const fileLogger = new Logger('FILE', '📁', '#f59e0b'); export const statsLogger = new Logger('STATS', '📊', '#22c55e'); export const apiLogger = new Logger('API', '🌐', '#3b82f6'); -export const authLogger = new Logger('AUTH', '🔐', '#dc2626'); -export const systemLogger = new Logger('SYSTEM', '🚀', '#1e3a8a'); +export const authLogger = new Logger('AUTH', '🔐', '#ef4444'); +export const systemLogger = new Logger('SYSTEM', '🚀', '#14b8a6'); +export const versionLogger = new Logger('VERSION', '📦', '#8b5cf6'); export const logger = systemLogger; diff --git a/src/components/CredentialSelector.tsx b/src/components/CredentialSelector.tsx index c0321f99..db97b44e 100644 --- a/src/components/CredentialSelector.tsx +++ b/src/components/CredentialSelector.tsx @@ -9,9 +9,10 @@ import type { Credential } from '../types/index.js'; interface CredentialSelectorProps { value?: number | null; onValueChange: (credentialId: number | null) => void; + onCredentialSelect?: (credential: Credential | null) => void; } -export function CredentialSelector({ value, onValueChange }: CredentialSelectorProps) { +export function CredentialSelector({ value, onValueChange, onCredentialSelect }: CredentialSelectorProps) { const { t } = useTranslation(); const [credentials, setCredentials] = useState([]); const [loading, setLoading] = useState(true); @@ -26,11 +27,12 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP try { setLoading(true); const data = await getCredentials(); - // Handle both possible response formats: direct array or nested object const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []); setCredentials(credentialsArray); } catch (error) { console.error('Failed to fetch credentials:', error); + const {toast} = await import('sonner'); + toast.error(t('credentials.failedToFetchCredentials')); setCredentials([]); } finally { setLoading(false); @@ -77,12 +79,18 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP const handleCredentialSelect = (credential: Credential) => { onValueChange(credential.id); + if (onCredentialSelect) { + onCredentialSelect(credential); + } setDropdownOpen(false); setSearchQuery(''); }; const handleClear = () => { onValueChange(null); + if (onCredentialSelect) { + onCredentialSelect(null); + } setDropdownOpen(false); setSearchQuery(''); }; @@ -101,6 +109,12 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP > {loading ? ( t('common.loading') + ) : value === "existing_credential" ? ( +
+
+ {t('hosts.existingCredential')} +
+
) : selectedCredential ? (
@@ -132,7 +146,7 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP />
-
+
{loading ? (
{t('common.loading')} @@ -142,13 +156,13 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP {searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')}
) : ( -
+
{value && ( -
- - - )} - /> + ( + + {t('credentials.sshPrivateKey')} + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ )} + /> +
([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [showViewer, setShowViewer] = useState(false); const [viewingCredential, setViewingCredential] = useState(null); + const [draggedCredential, setDraggedCredential] = useState(null); + const [dragOverFolder, setDragOverFolder] = useState(null); + const [editingFolder, setEditingFolder] = useState(null); + const [editingFolderName, setEditingFolderName] = useState(""); + const [operationLoading, setOperationLoading] = useState(false); + const dragCounter = useRef(0); useEffect(() => { fetchCredentials(); @@ -58,19 +70,139 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps const handleDelete = async (credentialId: number, credentialName: string) => { - if (window.confirm(t('credentials.confirmDeleteCredential', { name: credentialName }))) { - try { - await deleteCredential(credentialId); - toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName })); - await fetchCredentials(); - window.dispatchEvent(new CustomEvent('credentials:changed')); - } catch (err: any) { - if (err.response?.data?.details) { - toast.error(`${err.response.data.error}\n${err.response.data.details}`); - } else { - toast.error(t('credentials.failedToDeleteCredential')); + confirmWithToast( + t('credentials.confirmDeleteCredential', { name: credentialName }), + async () => { + try { + await deleteCredential(credentialId); + toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName })); + await fetchCredentials(); + window.dispatchEvent(new CustomEvent('credentials:changed')); + } catch (err: any) { + if (err.response?.data?.details) { + toast.error(`${err.response.data.error}\n${err.response.data.details}`); + } else { + toast.error(t('credentials.failedToDeleteCredential')); + } + } + }, + 'destructive' + ); + }; + + + const handleRemoveFromFolder = async (credential: Credential) => { + confirmWithToast( + t('credentials.confirmRemoveFromFolder', { name: credential.name || credential.username, folder: credential.folder }), + async () => { + try { + setOperationLoading(true); + const updatedCredential = { ...credential, folder: '' }; + await updateCredential(credential.id, updatedCredential); + toast.success(t('credentials.removedFromFolder', { name: credential.name || credential.username })); + await fetchCredentials(); + window.dispatchEvent(new CustomEvent('credentials:changed')); + } catch (err) { + toast.error(t('credentials.failedToRemoveFromFolder')); + } finally { + setOperationLoading(false); } } + ); + }; + + const handleFolderRename = async (oldName: string) => { + if (!editingFolderName.trim() || editingFolderName === oldName) { + setEditingFolder(null); + setEditingFolderName(''); + return; + } + + try { + setOperationLoading(true); + await renameCredentialFolder(oldName, editingFolderName.trim()); + toast.success(t('credentials.folderRenamed', { oldName, newName: editingFolderName.trim() })); + await fetchCredentials(); + window.dispatchEvent(new CustomEvent('credentials:changed')); + setEditingFolder(null); + setEditingFolderName(''); + } catch (err) { + toast.error(t('credentials.failedToRenameFolder')); + } finally { + setOperationLoading(false); + } + }; + + const startFolderEdit = (folderName: string) => { + setEditingFolder(folderName); + setEditingFolderName(folderName); + }; + + const cancelFolderEdit = () => { + setEditingFolder(null); + setEditingFolderName(''); + }; + + // Drag and drop handlers + const handleDragStart = (e: React.DragEvent, credential: Credential) => { + setDraggedCredential(credential); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', ''); // Required for Firefox + }; + + const handleDragEnd = () => { + setDraggedCredential(null); + setDragOverFolder(null); + dragCounter.current = 0; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDragEnter = (e: React.DragEvent, folderName: string) => { + e.preventDefault(); + dragCounter.current++; + setDragOverFolder(folderName); + }; + + const handleDragLeave = (e: React.DragEvent) => { + dragCounter.current--; + if (dragCounter.current === 0) { + setDragOverFolder(null); + } + }; + + const handleDrop = async (e: React.DragEvent, targetFolder: string) => { + e.preventDefault(); + dragCounter.current = 0; + setDragOverFolder(null); + + if (!draggedCredential) return; + + const newFolder = targetFolder === t('credentials.uncategorized') ? '' : targetFolder; + + if (draggedCredential.folder === newFolder) { + setDraggedCredential(null); + return; + } + + try { + setOperationLoading(true); + const updatedCredential = { ...draggedCredential, folder: newFolder }; + await updateCredential(draggedCredential.id, updatedCredential); + toast.success(t('credentials.movedToFolder', { + name: draggedCredential.name || draggedCredential.username, + folder: targetFolder + })); + await fetchCredentials(); + window.dispatchEvent(new CustomEvent('credentials:changed')); + } catch (err) { + toast.error(t('credentials.failedToMoveToFolder')); + } finally { + setOperationLoading(false); + setDraggedCredential(null); } }; @@ -150,13 +282,29 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps if (credentials.length === 0) { return ( -
-
- -

{t('credentials.noCredentials')}

-

- {t('credentials.noCredentialsMessage')} -

+
+
+
+

{t('credentials.sshCredentials')}

+

+ {t('credentials.credentialsCount', { count: 0 })} +

+
+
+ +
+
+ +
+
+ +

{t('credentials.noCredentials')}

+

+ {t('credentials.noCredentialsMessage')} +

+
); @@ -191,14 +339,90 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => ( -
+
handleDragEnter(e, folder)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, folder)} + > -
+
- {folder} + {editingFolder === folder ? ( +
e.stopPropagation()}> + setEditingFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleFolderRename(folder); + if (e.key === 'Escape') cancelFolderEdit(); + }} + className="h-6 text-sm px-2 flex-1" + autoFocus + disabled={operationLoading} + /> + + +
+ ) : ( + <> + { + e.stopPropagation(); + if (folder !== t('credentials.uncategorized')) { + startFolderEdit(folder); + } + }} + title={folder !== t('credentials.uncategorized') ? 'Click to rename folder' : ''} + > + {folder} + + {folder !== t('credentials.uncategorized') && ( + + )} + + )} {folderCredentials.length} @@ -207,87 +431,138 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
{folderCredentials.map((credential) => ( -
handleEdit(credential)} - > -
-
-
-

- {credential.name || `${credential.username}`} -

-
-

- {credential.username} -

-

- {credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')} -

-
-
- - -
-
+
+
+
+

+ {credential.name || `${credential.username}`} +

+
+

+ {credential.username} +

+

+ {credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')} +

+
+
+ {credential.folder && credential.folder !== '' && ( + + + + + +

Remove from folder "{credential.folder}"

+
+
+ )} + + + + + +

Edit credential

+
+
+ + + + + +

Delete credential

+
+
+
+
-
- {credential.tags && credential.tags.length > 0 && ( -
- {credential.tags.slice(0, 6).map((tag, index) => ( - - - {tag} - - ))} - {credential.tags.length > 6 && ( - - +{credential.tags.length - 6} - - )} +
+ {credential.tags && credential.tags.length > 0 && ( +
+ {credential.tags.slice(0, 6).map((tag, index) => ( + + + {tag} + + ))} + {credential.tags.length > 6 && ( + + +{credential.tags.length - 6} + + )} +
+ )} + +
+ + {credential.authType === 'password' ? ( + + ) : ( + + )} + {credential.authType} + + {credential.authType === 'key' && credential.keyType && ( + + {credential.keyType} + + )} +
+
- )} - -
- - {credential.authType === 'password' ? ( - - ) : ( - - )} - {credential.authType} - - {credential.authType === 'key' && credential.keyType && ( - - {credential.keyType} - - )} -
-
-
+ + +
+

Click to edit credential

+

Drag to move between folders

+
+
+ + ))}
diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index 5b6902be..a7a3f842 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -377,7 +377,12 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} loading: false } : t)); - toast.success(t('fileManager.fileSavedSuccessfully')); + // Handle toast notification from backend + if (result?.toast) { + toast[result.toast.type](result.toast.message); + } else { + toast.success(t('fileManager.fileSavedSuccessfully')); + } Promise.allSettled([ (async () => { @@ -390,12 +395,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} hostId: currentHost.id }); } catch (recentErr) { + console.error('Failed to add recent file:', recentErr); } })(), (async () => { try { await fetchHomeData(); } catch (refreshErr) { + console.error('Failed to refresh home data:', refreshErr); } })() ]).then(() => { @@ -451,8 +458,15 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} try { const {deleteSSHItem} = await import('@/ui/main-axios.ts'); - await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory'); - toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`); + const response = await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory'); + + // Handle toast notification from backend + if (response?.toast) { + toast[response.toast.type](response.toast.message); + } else { + toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`); + } + setDeletingItem(null); handleOperationComplete(); } catch (error: any) { diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx index 36ce4c13..bc2100e7 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx @@ -71,16 +71,33 @@ export function FileManagerOperations({ if (!uploadFile || !sshSessionId) return; setIsLoading(true); + + // Show loading toast + const {toast} = await import('sonner'); + const loadingToast = toast.loading(t('fileManager.uploadingFile', { name: uploadFile.name })); + try { const content = await uploadFile.text(); const {uploadSSHFile} = await import('@/ui/main-axios.ts'); - await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content); - onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name })); + const response = await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content); + + // Dismiss loading toast and show success + toast.dismiss(loadingToast); + + // Handle toast notification from backend + 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) { + // Dismiss loading toast and show error + toast.dismiss(loadingToast); onError(error?.response?.data?.error || t('fileManager.failedToUploadFile')); } finally { setIsLoading(false); @@ -91,15 +108,32 @@ export function FileManagerOperations({ if (!newFileName.trim() || !sshSessionId) return; setIsLoading(true); + + // Show loading toast + const {toast} = await import('sonner'); + const loadingToast = toast.loading(t('fileManager.creatingFile', { name: newFileName.trim() })); + try { const {createSSHFile} = await import('@/ui/main-axios.ts'); - await createSSHFile(sshSessionId, currentPath, newFileName.trim()); - onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() })); + const response = await createSSHFile(sshSessionId, currentPath, newFileName.trim()); + + // Dismiss loading toast + toast.dismiss(loadingToast); + + // Handle toast notification from backend + 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) { + // Dismiss loading toast and show error + toast.dismiss(loadingToast); onError(error?.response?.data?.error || t('fileManager.failedToCreateFile')); } finally { setIsLoading(false); @@ -110,15 +144,32 @@ export function FileManagerOperations({ if (!newFolderName.trim() || !sshSessionId) return; setIsLoading(true); + + // Show loading toast + const {toast} = await import('sonner'); + const loadingToast = toast.loading(t('fileManager.creatingFolder', { name: newFolderName.trim() })); + try { const {createSSHFolder} = await import('@/ui/main-axios.ts'); - await createSSHFolder(sshSessionId, currentPath, newFolderName.trim()); - onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() })); + const response = await createSSHFolder(sshSessionId, currentPath, newFolderName.trim()); + + // Dismiss loading toast + toast.dismiss(loadingToast); + + // Handle toast notification from backend + 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) { + // Dismiss loading toast and show error + toast.dismiss(loadingToast); onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder')); } finally { setIsLoading(false); @@ -129,16 +180,36 @@ export function FileManagerOperations({ if (!deletePath || !sshSessionId) return; setIsLoading(true); + + // Show loading toast + 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'); - await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory); - onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') })); + const response = await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory); + + // Dismiss loading toast + toast.dismiss(loadingToast); + + // Handle toast notification from backend + 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) { + // Dismiss loading toast and show error + toast.dismiss(loadingToast); onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem')); } finally { setIsLoading(false); @@ -149,17 +220,38 @@ export function FileManagerOperations({ if (!renamePath || !newName.trim() || !sshSessionId) return; setIsLoading(true); + + // Show loading toast + 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'); - await renameSSHItem(sshSessionId, renamePath, newName.trim()); - onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') })); + const response = await renameSSHItem(sshSessionId, renamePath, newName.trim()); + + // Dismiss loading toast + toast.dismiss(loadingToast); + + // Handle toast notification from backend + 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) { + // Dismiss loading toast and show error + toast.dismiss(loadingToast); onError(error?.response?.data?.error || t('fileManager.failedToRenameItem')); } finally { setIsLoading(false); diff --git a/src/ui/Desktop/Apps/Host Manager/HostManager.tsx b/src/ui/Desktop/Apps/Host Manager/HostManager.tsx index 93880a11..5a05f628 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManager.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManager.tsx @@ -23,11 +23,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea }; const handleFormSubmit = (updatedHost?: SSHHost) => { - if (updatedHost) { - setEditingHost(updatedHost); - } else { - setEditingHost(null); - } + setEditingHost(null); setActiveTab("host_viewer"); }; @@ -44,10 +40,11 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea const handleTabChange = (value: string) => { setActiveTab(value); - if (value === "host_viewer") { + // Reset editing states when switching away from edit tabs + if (value !== "add_host") { setEditingHost(null); } - if (value === "credentials") { + if (value !== "add_credential") { setEditingCredential(null); } }; diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx index d323cccc..9f8939c9 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx @@ -20,7 +20,7 @@ import React, {useEffect, useRef, useState} from "react"; import {Switch} from "@/components/ui/switch.tsx"; import {Alert, AlertDescription} from "@/components/ui/alert.tsx"; import {toast} from "sonner"; -import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts'; +import {createSSHHost, updateSSHHost, getSSHHosts, getCredentials} from '@/ui/main-axios.ts'; import {useTranslation} from "react-i18next"; import {CredentialSelector} from "@/components/CredentialSelector.tsx"; @@ -58,6 +58,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi const [hosts, setHosts] = useState([]); const [folders, setFolders] = useState([]); const [sshConfigurations, setSshConfigurations] = useState([]); + const [credentials, setCredentials] = useState([]); const [loading, setLoading] = useState(true); const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password'); @@ -71,8 +72,12 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi const fetchData = async () => { try { setLoading(true); - const hostsData = await getSSHHosts(); + const [hostsData, credentialsData] = await Promise.all([ + getSSHHosts(), + getCredentials() + ]); setHosts(hostsData); + setCredentials(credentialsData); const uniqueFolders = [...new Set( hostsData @@ -97,6 +102,43 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi fetchData(); }, []); + // Listen for credential changes to refresh the credential list + useEffect(() => { + const handleCredentialChange = async () => { + try { + setLoading(true); + const hostsData = await getSSHHosts(); + setHosts(hostsData); + + const uniqueFolders = [...new Set( + hostsData + .filter(host => host.folder && host.folder.trim() !== '') + .map(host => host.folder) + )].sort(); + + const uniqueConfigurations = [...new Set( + hostsData + .filter(host => host.name && host.name.trim() !== '') + .map(host => host.name) + )].sort(); + + setFolders(uniqueFolders); + setSshConfigurations(uniqueConfigurations); + } catch (error) { + // Handle error silently + } finally { + setLoading(false); + } + }; + + window.addEventListener('credentials:changed', handleCredentialChange); + + return () => { + window.removeEventListener('credentials:changed', handleCredentialChange); + }; + }, []); + + const formSchema = z.object({ name: z.string().optional(), ip: z.string().min(1), @@ -143,7 +185,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi }); } } else if (data.authType === 'key') { - if (!data.key) { + if (!data.key || (typeof data.key === 'string' && data.key.trim() === '')) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t('hosts.sshKeyRequired'), @@ -158,7 +200,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi }); } } else if (data.authType === 'credential') { - if (!data.credentialId) { + if (!data.credentialId || (typeof data.credentialId === 'string' && data.credentialId.trim() === '')) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t('hosts.credentialRequired'), @@ -204,31 +246,66 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi } }); + // Update username when switching to credential tab and a credential is selected + useEffect(() => { + if (authTab === 'credential') { + const currentCredentialId = form.getValues('credentialId'); + if (currentCredentialId) { + const selectedCredential = credentials.find(c => c.id === currentCredentialId); + if (selectedCredential) { + form.setValue('username', selectedCredential.username); + } + } + } + }, [authTab, credentials, form]); + useEffect(() => { if (editingHost) { - const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password'); + const cleanedHost = { ...editingHost }; + if (cleanedHost.credentialId && cleanedHost.key) { + cleanedHost.key = undefined; + cleanedHost.keyPassword = undefined; + cleanedHost.keyType = undefined; + } else if (cleanedHost.credentialId && cleanedHost.password) { + cleanedHost.password = undefined; + } else if (cleanedHost.key && cleanedHost.password) { + cleanedHost.password = undefined; + } + + const defaultAuthType = cleanedHost.credentialId ? 'credential' : (cleanedHost.key ? 'key' : 'password'); setAuthTab(defaultAuthType); const formData = { - name: editingHost.name || "", - ip: editingHost.ip || "", - port: editingHost.port || 22, - username: editingHost.username || "", - folder: editingHost.folder || "", - tags: editingHost.tags || [], - pin: Boolean(editingHost.pin), + name: cleanedHost.name || "", + ip: cleanedHost.ip || "", + port: cleanedHost.port || 22, + username: cleanedHost.username || "", + folder: cleanedHost.folder || "", + tags: cleanedHost.tags || [], + pin: Boolean(cleanedHost.pin), authType: defaultAuthType as 'password' | 'key' | 'credential', - credentialId: editingHost.credentialId || null, - password: editingHost.password || "", + credentialId: null, + password: "", key: null, - keyPassword: editingHost.keyPassword || "", - keyType: (editingHost.keyType as any) || "auto", - enableTerminal: Boolean(editingHost.enableTerminal), - enableTunnel: Boolean(editingHost.enableTunnel), - enableFileManager: Boolean(editingHost.enableFileManager), - defaultPath: editingHost.defaultPath || "/", - tunnelConnections: editingHost.tunnelConnections || [], + keyPassword: "", + keyType: "auto" as const, + enableTerminal: Boolean(cleanedHost.enableTerminal), + enableTunnel: Boolean(cleanedHost.enableTunnel), + enableFileManager: Boolean(cleanedHost.enableFileManager), + defaultPath: cleanedHost.defaultPath || "/", + tunnelConnections: cleanedHost.tunnelConnections || [], }; + + // Only set the relevant authentication fields based on authType + if (defaultAuthType === 'password') { + formData.password = cleanedHost.password || ""; + } else if (defaultAuthType === 'key') { + formData.key = "existing_key"; // Placeholder to indicate existing key + formData.keyPassword = cleanedHost.keyPassword || ""; + formData.keyType = (cleanedHost.keyType as any) || "auto"; + } else if (defaultAuthType === 'credential') { + formData.credentialId = cleanedHost.credentialId || "existing_credential"; + } form.reset(formData); } else { @@ -292,24 +369,26 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi tunnelConnections: data.tunnelConnections || [] }; + submitData.credentialId = null; + submitData.password = null; + submitData.key = null; + submitData.keyPassword = null; + submitData.keyType = null; + if (data.authType === 'credential') { - submitData.credentialId = data.credentialId; - submitData.password = null; - submitData.key = null; - submitData.keyPassword = null; - submitData.keyType = null; + if (data.credentialId === "existing_credential") { + delete submitData.credentialId; + } else { + submitData.credentialId = data.credentialId; + } } else if (data.authType === 'password') { - submitData.credentialId = null; submitData.password = data.password; - submitData.key = null; - submitData.keyPassword = null; - submitData.keyType = null; } else if (data.authType === 'key') { - submitData.credentialId = null; - submitData.password = null; if (data.key instanceof File) { const keyContent = await data.key.text(); submitData.key = keyContent; + } else if (data.key === "existing_key") { + delete submitData.key; } else { submitData.key = data.key; } @@ -334,6 +413,9 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi } window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); + + // Reset form after successful submission + form.reset(); } catch (error) { toast.error(t('hosts.failedToSaveHost')); } finally { @@ -663,7 +745,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi setAuthTab(newAuthType); form.setValue('authType', newAuthType); - // Clear other auth fields when switching + // Clear authentication fields based on what we're switching away from if (newAuthType === 'password') { form.setValue('key', null); form.setValue('keyPassword', ''); @@ -744,7 +826,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi > - {field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')} + {field.value === "existing_key" ? t('hosts.existingKey') : + field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
@@ -843,10 +926,21 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi control={form.control} name="credentialId" render={({ field }) => ( - + + { + if (credential) { + // Update username when credential is selected + form.setValue('username', credential.username); + } + }} + /> + + {t('hosts.credentialDescription')} + + )} /> diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx index 90b8b1d7..2838d9b4 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx @@ -9,6 +9,7 @@ import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/compon import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder} from "@/ui/main-axios.ts"; import {toast} from "sonner"; import {useTranslation} from "react-i18next"; +import {useConfirmation} from "@/hooks/use-confirmation.ts"; import { Edit, Trash2, @@ -24,13 +25,15 @@ import { Info, X, Check, - Pencil + Pencil, + FolderMinus } from "lucide-react"; import {Separator} from "@/components/ui/separator.tsx"; -import type { SSHHost, SSHManagerHostViewerProps } from '../../../types/index.js'; +import type { SSHHost, SSHManagerHostViewerProps } from '../../../../types/index.js'; export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) { const {t} = useTranslation(); + const {confirmWithToast} = useConfirmation(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -66,7 +69,25 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) { try { setLoading(true); const data = await getSSHHosts(); - setHosts(data); + + const cleanedHosts = data.map(host => { + const cleanedHost = { ...host }; + if (cleanedHost.credentialId && cleanedHost.key) { + cleanedHost.key = undefined; + cleanedHost.keyPassword = undefined; + cleanedHost.keyType = undefined; + cleanedHost.authType = 'credential'; + } else if (cleanedHost.credentialId && cleanedHost.password) { + cleanedHost.password = undefined; + cleanedHost.authType = 'credential'; + } else if (cleanedHost.key && cleanedHost.password) { + cleanedHost.password = undefined; + cleanedHost.authType = 'key'; + } + return cleanedHost; + }); + + setHosts(cleanedHosts); setError(null); } catch (err) { setError(t('hosts.failedToLoadHosts')); @@ -76,47 +97,92 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) { }; const handleDelete = async (hostId: number, hostName: string) => { - if (window.confirm(t('hosts.confirmDelete', { name: hostName }))) { - try { - await deleteSSHHost(hostId); - toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName })); - await fetchHosts(); - window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); - } catch (err) { - toast.error(t('hosts.failedToDeleteHost')); - } - } + confirmWithToast( + t('hosts.confirmDelete', { name: hostName }), + async () => { + try { + await deleteSSHHost(hostId); + toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName })); + await fetchHosts(); + window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); + } catch (err) { + toast.error(t('hosts.failedToDeleteHost')); + } + }, + 'destructive' + ); }; const handleExport = (host: SSHHost) => { - const exportData = { - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - authType: host.authType, - folder: host.folder, - tags: host.tags, - pin: host.pin, - enableTerminal: host.enableTerminal, - enableTunnel: host.enableTunnel, - enableFileManager: host.enableFileManager, - defaultPath: host.defaultPath, - tunnelConnections: host.tunnelConnections, + const actualAuthType = host.credentialId ? 'credential' : (host.key ? 'key' : 'password'); + + // Check if host uses sensitive authentication data + if (actualAuthType === 'credential') { + const confirmMessage = t('hosts.exportCredentialWarning', { + name: host.name || `${host.username}@${host.ip}` + }); + + confirmWithToast(confirmMessage, () => { + performExport(host, actualAuthType); + }); + return; + } else if (actualAuthType === 'password' || actualAuthType === 'key') { + const confirmMessage = t('hosts.exportSensitiveDataWarning', { + name: host.name || `${host.username}@${host.ip}` + }); + + confirmWithToast(confirmMessage, () => { + performExport(host, actualAuthType); + }); + return; + } + + // No sensitive data, proceed directly + performExport(host, actualAuthType); }; - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${host.name || host.username + '@' + host.ip}-credentials.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + const performExport = (host: SSHHost, actualAuthType: string) => { - toast.success(`Exported credentials for ${host.name || host.username}@${host.ip}`); -}; + // Create export data with sensitive fields excluded + const exportData: any = { + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + authType: actualAuthType, // Use the determined authType, not the stored one + folder: host.folder, + tags: host.tags, + pin: host.pin, + enableTerminal: host.enableTerminal, + enableTunnel: host.enableTunnel, + enableFileManager: host.enableFileManager, + defaultPath: host.defaultPath, + tunnelConnections: host.tunnelConnections, + }; + + // Only include credentialId if actualAuthType is credential, but set it to null for security + if (actualAuthType === 'credential') { + exportData.credentialId = null; // Set to null instead of undefined so it's included but empty + } + + // Remove undefined values from export, but keep null values + const cleanExportData = Object.fromEntries( + Object.entries(exportData).filter(([_, value]) => value !== undefined) + ); + + + const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${host.name || host.username + '@' + host.ip}-host-config.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success(`Exported host configuration for ${host.name || host.username}@${host.ip}`); + }; const handleEdit = (host: SSHHost) => { @@ -126,20 +192,23 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) { }; const handleRemoveFromFolder = async (host: SSHHost) => { - if (window.confirm(t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }))) { - try { - setOperationLoading(true); - const updatedHost = { ...host, folder: '' }; - await updateSSHHost(host.id, updatedHost); - toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` })); - await fetchHosts(); - window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); - } catch (err) { - toast.error(t('hosts.failedToRemoveFromFolder')); - } finally { - setOperationLoading(false); + confirmWithToast( + t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }), + async () => { + try { + setOperationLoading(true); + const updatedHost = { ...host, folder: '' }; + await updateSSHHost(host.id, updatedHost); + toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` })); + await fetchHosts(); + window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); + } catch (err) { + toast.error(t('hosts.failedToRemoveFromFolder')); + } finally { + setOperationLoading(false); + } } - } + ); }; const handleFolderRename = async (oldName: string) => { @@ -400,51 +469,66 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) { variant="outline" size="sm" onClick={() => { - const sampleData = { - hosts: [ + const sampleData = { + hosts: [ + { + name: "Web Server - Production", + ip: "192.168.1.100", + port: 22, + username: "admin", + authType: "password", + password: "your_secure_password_here", + folder: "Production", + tags: ["web", "production", "nginx"], + pin: true, + enableTerminal: true, + enableTunnel: false, + enableFileManager: true, + defaultPath: "/var/www" + }, + { + name: "Database Server", + ip: "192.168.1.101", + port: 22, + username: "dbadmin", + authType: "key", + 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", + tags: ["database", "production", "postgresql"], + pin: false, + enableTerminal: true, + enableTunnel: true, + enableFileManager: false, + tunnelConnections: [ { - name: "Web Server - Production", - ip: "192.168.1.100", - port: 22, - username: "admin", - authType: "password", - password: "your_secure_password_here", - folder: "Production", - tags: ["web", "production", "nginx"], - pin: true, - enableTerminal: true, - enableTunnel: false, - enableFileManager: true, - defaultPath: "/var/www" - }, - { - name: "Database Server", - ip: "192.168.1.101", - port: 22, - username: "dbadmin", - authType: "key", - 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", - tags: ["database", "production", "postgresql"], - pin: false, - enableTerminal: true, - enableTunnel: true, - enableFileManager: false, - tunnelConnections: [ - { - sourcePort: 5432, - endpointPort: 5432, - endpointHost: "Web Server - Production", - maxRetries: 3, - retryInterval: 10, - autoStart: true - } - ] + sourcePort: 5432, + endpointPort: 5432, + endpointHost: "Web Server - Production", + maxRetries: 3, + retryInterval: 10, + autoStart: true } ] - }; + }, + { + name: "Development Server", + ip: "192.168.1.102", + port: 2222, + username: "developer", + authType: "credential", + credentialId: 1, + folder: "Development", + tags: ["dev", "testing"], + pin: false, + enableTerminal: true, + enableTunnel: false, + enableFileManager: true, + defaultPath: "/home/developer" + } + ] + }; const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'}); const url = URL.createObjectURL(blob); @@ -478,6 +562,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
+ {t('hosts.noHostsMessage')}

-

- {t('hosts.getStartedMessage', { defaultValue: 'Use the Import JSON button above to add hosts from a JSON file.' })} -

@@ -583,6 +665,21 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) { autoStart: true } ] + }, + { + name: "Development Server", + ip: "192.168.1.102", + port: 2222, + username: "developer", + authType: "credential", + credentialId: 1, + folder: "Development", + tags: ["dev", "testing"], + pin: false, + enableTerminal: true, + enableTunnel: false, + enableFileManager: true, + defaultPath: "/home/developer" } ] }; @@ -619,6 +716,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
+
{folderHosts.map((host) => ( -
handleDragStart(e, host)} - onDragEnd={handleDragEnd} - className={`bg-[#222225] border border-input rounded cursor-move hover:shadow-md transition-all p-2 ${ - draggedHost?.id === host.id ? 'opacity-50 scale-95' : '' - }`} - onClick={() => handleEdit(host)} - > -
-
-
- {host.pin && } -

- {host.name || `${host.username}@${host.ip}`} -

-
+ + + +
handleDragStart(e, host)} + onDragEnd={handleDragEnd} + className={`bg-[#222225] border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-[#2a2a2d] transition-all duration-200 p-3 group relative ${ + draggedHost?.id === host.id ? 'opacity-50 scale-95' : '' + }`} + onClick={() => handleEdit(host)} + > +
+
+
+ {host.pin && } +

+ {host.name || `${host.username}@${host.ip}`} +

+

{host.ip}:{host.port}

@@ -760,53 +860,80 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
{host.folder && host.folder !== '' && ( - + + + + + +

Remove from folder "{host.folder}"

+
+
)} - - - + + + + + +

Edit host

+
+
+ + + + + +

Delete host

+
+
+ + + + + +

Export host

+
+
@@ -856,6 +983,15 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
+ + +
+

Click to edit host

+

Drag to move between folders

+
+
+ + ))}
diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx index b58b3410..5952b120 100644 --- a/src/ui/Desktop/Apps/Server/Server.tsx +++ b/src/ui/Desktop/Apps/Server/Server.tsx @@ -9,6 +9,7 @@ import {Tunnel} from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx"; import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts"; import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; interface ServerProps { hostConfig?: any; @@ -47,6 +48,8 @@ export function Server({ setCurrentHostConfig(updatedHost); } } catch (error) { + console.error('Failed to fetch latest host config:', error); + toast.error(t('serverStats.failedToFetchHostConfig')); } } }; @@ -63,6 +66,8 @@ export function Server({ setCurrentHostConfig(updatedHost); } } catch (error) { + console.error('Failed to fetch updated host config:', error); + toast.error(t('serverStats.failedToFetchHostConfig')); } } }; @@ -81,8 +86,12 @@ export function Server({ if (!cancelled) { setServerStatus(res?.status === 'online' ? 'online' : 'offline'); } - } catch { - if (!cancelled) setServerStatus('offline'); + } catch (error) { + console.error('Failed to fetch server status:', error); + if (!cancelled) { + setServerStatus('offline'); + toast.error(t('serverStats.failedToFetchStatus')); + } } }; @@ -91,8 +100,12 @@ export function Server({ try { const data = await getServerMetricsById(currentHostConfig.id); if (!cancelled) setMetrics(data); - } catch { - if (!cancelled) setMetrics(null); + } catch (error) { + console.error('Failed to fetch server metrics:', error); + if (!cancelled) { + setMetrics(null); + toast.error(t('serverStats.failedToFetchMetrics')); + } } }; diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 2b57c94f..cb545a81 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -5,6 +5,7 @@ import {ClipboardAddon} from '@xterm/addon-clipboard'; import {Unicode11Addon} from '@xterm/addon-unicode11'; import {WebLinksAddon} from '@xterm/addon-web-links'; import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; interface SSHTerminalProps { hostConfig: any; @@ -26,7 +27,12 @@ export const Terminal = forwardRef(function SSHTerminal( const wasDisconnectedBySSH = useRef(false); const pingIntervalRef = useRef(null); const [visible, setVisible] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const [connectionError, setConnectionError] = useState(null); const isVisibleRef = useRef(false); + const reconnectTimeoutRef = useRef(null); + const reconnectAttempts = useRef(0); + const maxReconnectAttempts = 3; const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); @@ -69,7 +75,12 @@ export const Terminal = forwardRef(function SSHTerminal( clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } webSocketRef.current?.close(); + setIsConnected(false); }, fit: () => { fitAddonRef.current?.fit(); @@ -118,10 +129,51 @@ export const Terminal = forwardRef(function SSHTerminal( return getCookie("rightClickCopyPaste") === "true" } + function attemptReconnection() { + if (reconnectAttempts.current >= maxReconnectAttempts) { + toast.error(t('terminal.maxReconnectAttemptsReached')); + return; + } + + reconnectAttempts.current++; + toast.info(t('terminal.reconnecting', { attempt: reconnectAttempts.current, max: maxReconnectAttempts })); + + reconnectTimeoutRef.current = setTimeout(() => { + if (terminal && hostConfig) { + const cols = terminal.cols; + const rows = terminal.rows; + connectToHost(cols, rows); + } + }, 2000 * reconnectAttempts.current); // Exponential backoff + } + + function connectToHost(cols: number, rows: number) { + const isDev = process.env.NODE_ENV === 'development' && + (window.location.port === '3000' || window.location.port === '5173' || window.location.port === ''); + + const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true; + + const wsUrl = isDev + ? 'ws://localhost:8082' + : isElectron + ? 'ws://127.0.0.1:8082' + : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; + + const ws = new WebSocket(wsUrl); + webSocketRef.current = ws; + wasDisconnectedBySSH.current = false; + setConnectionError(null); + + setupWebSocketListeners(ws, cols, rows); + } + function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) { ws.addEventListener('open', () => { + setIsConnected(true); + reconnectAttempts.current = 0; // Reset on successful connection + toast.success(t('terminal.connected')); ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); terminal.onData((data) => { @@ -133,32 +185,72 @@ export const Terminal = forwardRef(function SSHTerminal( ws.send(JSON.stringify({type: 'ping'})); } }, 30000); - - }); ws.addEventListener('message', (event) => { try { const msg = JSON.parse(event.data); - if (msg.type === 'data') terminal.write(msg.data); - else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`); - else if (msg.type === 'connected') { + if (msg.type === 'data') { + terminal.write(msg.data); + } else if (msg.type === 'error') { + // Handle different types of errors + const errorMessage = msg.message || t('terminal.unknownError'); + + // Check if it's an authentication error + if (errorMessage.toLowerCase().includes('auth') || + errorMessage.toLowerCase().includes('password') || + errorMessage.toLowerCase().includes('permission') || + errorMessage.toLowerCase().includes('denied')) { + toast.error(t('terminal.authError', { message: errorMessage })); + // Close terminal on auth errors + if (webSocketRef.current) { + webSocketRef.current.close(); + } + return; + } + + // Check if it's a connection error that should trigger reconnection + if (errorMessage.toLowerCase().includes('connection') || + errorMessage.toLowerCase().includes('timeout') || + errorMessage.toLowerCase().includes('network')) { + toast.error(t('terminal.connectionError', { message: errorMessage })); + setIsConnected(false); + attemptReconnection(); + return; + } + + // For other errors, show toast but don't close terminal + toast.error(t('terminal.error', { message: errorMessage })); + } else if (msg.type === 'connected') { + setIsConnected(true); + toast.success(t('terminal.sshConnected')); } else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; - terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`); + setIsConnected(false); + toast.info(t('terminal.disconnected', { message: msg.message })); + // Attempt reconnection for disconnections + attemptReconnection(); } } catch (error) { + toast.error(t('terminal.messageParseError')); } }); - ws.addEventListener('close', () => { + ws.addEventListener('close', (event) => { + setIsConnected(false); if (!wasDisconnectedBySSH.current) { - terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`); + toast.warning(t('terminal.connectionClosed')); + // Attempt reconnection for unexpected disconnections + attemptReconnection(); } }); - ws.addEventListener('error', () => { - terminal.writeln(`\r\n[${t('terminal.connectionError')}]`); + ws.addEventListener('error', (event) => { + setIsConnected(false); + setConnectionError(t('terminal.websocketError')); + toast.error(t('terminal.websocketError')); + // Attempt reconnection for WebSocket errors + attemptReconnection(); }); } @@ -288,11 +380,7 @@ export const Terminal = forwardRef(function SSHTerminal( ? 'ws://127.0.0.1:8082' : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; - const ws = new WebSocket(wsUrl); - webSocketRef.current = ws; - wasDisconnectedBySSH.current = false; - - setupWebSocketListeners(ws, cols, rows); + connectToHost(cols, rows); }, 300); }); @@ -301,6 +389,7 @@ export const Terminal = forwardRef(function SSHTerminal( element?.removeEventListener('contextmenu', handleContextMenu); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; @@ -341,16 +430,29 @@ export const Terminal = forwardRef(function SSHTerminal( }, [splitScreen, isVisible, terminal]); return ( -
{ - if (terminal && !splitScreen) { - terminal.focus(); - } - }} - /> +
+ {/* Connection Status Indicator */} + {!isConnected && ( +
+ {t('terminal.disconnected')} +
+ )} + {isConnected && ( +
+ {t('terminal.connected')} +
+ )} +
{ + if (terminal && !splitScreen) { + terminal.focus(); + } + }} + /> +
); }); diff --git a/src/ui/Desktop/Navigation/LeftSidebar.tsx b/src/ui/Desktop/Navigation/LeftSidebar.tsx index ae217896..e03bc1b3 100644 --- a/src/ui/Desktop/Navigation/LeftSidebar.tsx +++ b/src/ui/Desktop/Navigation/LeftSidebar.tsx @@ -115,7 +115,7 @@ export function LeftSidebar({ const [isSidebarOpen, setIsSidebarOpen] = useState(true); - const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any; + const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab, updateHostConfig} = useTabs() as any; const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); const openSshManagerTab = () => { @@ -171,7 +171,17 @@ export function LeftSidebar({ newHost.username !== existingHost.username || newHost.pin !== existingHost.pin || newHost.enableTerminal !== existingHost.enableTerminal || - JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags) + newHost.enableTunnel !== existingHost.enableTunnel || + newHost.enableFileManager !== existingHost.enableFileManager || + newHost.authType !== existingHost.authType || + newHost.password !== existingHost.password || + newHost.key !== existingHost.key || + newHost.keyPassword !== existingHost.keyPassword || + newHost.keyType !== existingHost.keyType || + newHost.credentialId !== existingHost.credentialId || + newHost.defaultPath !== existingHost.defaultPath || + JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags) || + JSON.stringify(newHost.tunnelConnections) !== JSON.stringify(existingHost.tunnelConnections) ) { hasChanges = true; break; @@ -183,12 +193,17 @@ export function LeftSidebar({ setTimeout(() => { setHosts(newHosts); prevHostsRef.current = newHosts; + + // Update hostConfig in existing tabs + newHosts.forEach(newHost => { + updateHostConfig(newHost.id, newHost); + }); }, 50); } } catch (err: any) { setHostsError(t('leftSidebar.failedToLoadHosts')); } - }, []); + }, [updateHostConfig]); React.useEffect(() => { fetchHosts(); @@ -200,8 +215,15 @@ export function LeftSidebar({ const handleHostsChanged = () => { fetchHosts(); }; + const handleCredentialsChanged = () => { + fetchHosts(); + }; window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); - return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); + window.addEventListener('credentials:changed', handleCredentialsChanged as EventListener); + return () => { + window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); + window.removeEventListener('credentials:changed', handleCredentialsChanged as EventListener); + }; }, [fetchHosts]); React.useEffect(() => { diff --git a/src/ui/Desktop/Navigation/Tabs/TabContext.tsx b/src/ui/Desktop/Navigation/Tabs/TabContext.tsx index 51d6a457..22fd790d 100644 --- a/src/ui/Desktop/Navigation/Tabs/TabContext.tsx +++ b/src/ui/Desktop/Navigation/Tabs/TabContext.tsx @@ -13,6 +13,7 @@ interface TabContextType { setCurrentTab: (tabId: number) => void; setSplitScreenTab: (tabId: number) => void; getTab: (tabId: number) => Tab | undefined; + updateHostConfig: (hostId: number, newHostConfig: any) => void; } const TabContext = createContext(undefined); @@ -111,6 +112,19 @@ export function TabProvider({children}: TabProviderProps) { return tabs.find(tab => tab.id === tabId); }; + const updateHostConfig = (hostId: number, newHostConfig: any) => { + setTabs(prev => prev.map(tab => { + if (tab.hostConfig && tab.hostConfig.id === hostId) { + return { + ...tab, + hostConfig: newHostConfig, + title: newHostConfig.name?.trim() ? newHostConfig.name : `${newHostConfig.username}@${newHostConfig.ip}:${newHostConfig.port}` + }; + } + return tab; + })); + }; + const value: TabContextType = { tabs, currentTab, @@ -120,6 +134,7 @@ export function TabProvider({children}: TabProviderProps) { setCurrentTab, setSplitScreenTab, getTab, + updateHostConfig, }; return ( diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index ae09c454..06b937bf 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -746,12 +746,14 @@ export async function readSSHFile(sessionId: string, path: string): Promise<{ co } } -export async function writeSSHFile(sessionId: string, path: string, content: string): Promise { +export async function writeSSHFile(sessionId: string, path: string, content: string, hostId?: number, userId?: string): Promise { try { const response = await fileManagerApi.post('/ssh/writeFile', { sessionId, path, - content + content, + hostId, + userId }); if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) { @@ -764,13 +766,15 @@ export async function writeSSHFile(sessionId: string, path: string, content: str } } -export async function uploadSSHFile(sessionId: string, path: string, fileName: string, content: string): Promise { +export async function uploadSSHFile(sessionId: string, path: string, fileName: string, content: string, hostId?: number, userId?: string): Promise { try { const response = await fileManagerApi.post('/ssh/uploadFile', { sessionId, path, fileName, - content + content, + hostId, + userId }); return response.data; } catch (error) { @@ -778,13 +782,15 @@ export async function uploadSSHFile(sessionId: string, path: string, fileName: s } } -export async function createSSHFile(sessionId: string, path: string, fileName: string, content: string = ''): Promise { +export async function createSSHFile(sessionId: string, path: string, fileName: string, content: string = '', hostId?: number, userId?: string): Promise { try { const response = await fileManagerApi.post('/ssh/createFile', { sessionId, path, fileName, - content + content, + hostId, + userId }); return response.data; } catch (error) { @@ -792,12 +798,14 @@ export async function createSSHFile(sessionId: string, path: string, fileName: s } } -export async function createSSHFolder(sessionId: string, path: string, folderName: string): Promise { +export async function createSSHFolder(sessionId: string, path: string, folderName: string, hostId?: number, userId?: string): Promise { try { const response = await fileManagerApi.post('/ssh/createFolder', { sessionId, path, - folderName + folderName, + hostId, + userId }); return response.data; } catch (error) { @@ -805,13 +813,15 @@ export async function createSSHFolder(sessionId: string, path: string, folderNam } } -export async function deleteSSHItem(sessionId: string, path: string, isDirectory: boolean): Promise { +export async function deleteSSHItem(sessionId: string, path: string, isDirectory: boolean, hostId?: number, userId?: string): Promise { try { const response = await fileManagerApi.delete('/ssh/deleteItem', { data: { sessionId, path, - isDirectory + isDirectory, + hostId, + userId } }); return response.data; @@ -820,12 +830,14 @@ export async function deleteSSHItem(sessionId: string, path: string, isDirectory } } -export async function renameSSHItem(sessionId: string, oldPath: string, newName: string): Promise { +export async function renameSSHItem(sessionId: string, oldPath: string, newName: string, hostId?: number, userId?: string): Promise { try { const response = await fileManagerApi.put('/ssh/renameItem', { sessionId, oldPath, - newName + newName, + hostId, + userId }); return response.data; } catch (error) { @@ -1276,4 +1288,16 @@ export async function renameFolder(oldName: string, newName: string): Promise { + try { + const response = await authApi.put('/credentials/folders/rename', { + oldName, + newName + }); + return response.data; + } catch (error) { + handleApiError(error, 'rename credential folder'); + } } \ No newline at end of file