v1.6.0 #221
@@ -37,7 +37,7 @@
|
|||||||
"loadingCredentials": "Loading credentials...",
|
"loadingCredentials": "Loading credentials...",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"noCredentials": "No Credentials",
|
"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",
|
"sshCredentials": "SSH Credentials",
|
||||||
"credentialsCount": "{{count}} credentials",
|
"credentialsCount": "{{count}} credentials",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
@@ -123,7 +123,14 @@
|
|||||||
"editCredentialDescription": "Update the credential information",
|
"editCredentialDescription": "Update the credential information",
|
||||||
"listView": "List",
|
"listView": "List",
|
||||||
"folderView": "Folders",
|
"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": {
|
"sshTools": {
|
||||||
"title": "SSH Tools",
|
"title": "SSH Tools",
|
||||||
@@ -310,6 +317,8 @@
|
|||||||
"allowNewAccountRegistration": "Allow new account registration",
|
"allowNewAccountRegistration": "Allow new account registration",
|
||||||
"missingRequiredFields": "Missing required fields: {{fields}}",
|
"missingRequiredFields": "Missing required fields: {{fields}}",
|
||||||
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
||||||
|
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
|
||||||
|
"failedToFetchRegistrationStatus": "Failed to fetch registration status",
|
||||||
"oidcConfigurationDisabled": "OIDC configuration disabled successfully!",
|
"oidcConfigurationDisabled": "OIDC configuration disabled successfully!",
|
||||||
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
|
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
|
||||||
"failedToDisableOidcConfig": "Failed to disable 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).",
|
"importJsonDesc": "Upload a JSON file to bulk import multiple SSH hosts (max 100).",
|
||||||
"downloadSample": "Download Sample",
|
"downloadSample": "Download Sample",
|
||||||
"formatGuide": "Format Guide",
|
"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",
|
"uncategorized": "Uncategorized",
|
||||||
"confirmDelete": "Are you sure you want to delete \"{{name}}\" ?",
|
"confirmDelete": "Are you sure you want to delete \"{{name}}\" ?",
|
||||||
"failedToDeleteHost": "Failed to delete host",
|
"failedToDeleteHost": "Failed to delete host",
|
||||||
@@ -413,6 +424,7 @@
|
|||||||
"selectCredential": "Select Credential",
|
"selectCredential": "Select Credential",
|
||||||
"selectCredentialPlaceholder": "Choose a credential...",
|
"selectCredentialPlaceholder": "Choose a credential...",
|
||||||
"credentialRequired": "Credential is required when using credential authentication",
|
"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",
|
"sshPrivateKey": "SSH Private Key",
|
||||||
"keyPassword": "Key Password",
|
"keyPassword": "Key Password",
|
||||||
"keyType": "Key Type",
|
"keyType": "Key Type",
|
||||||
@@ -428,6 +440,8 @@
|
|||||||
"uploadFile": "Upload File",
|
"uploadFile": "Upload File",
|
||||||
"pasteKey": "Paste Key",
|
"pasteKey": "Paste Key",
|
||||||
"updateKey": "Update Key",
|
"updateKey": "Update Key",
|
||||||
|
"existingKey": "Existing Key (click to change)",
|
||||||
|
"existingCredential": "Existing Credential (click to change)",
|
||||||
"addTagsSpaceToAdd": "add tags (space to add)",
|
"addTagsSpaceToAdd": "add tags (space to add)",
|
||||||
"terminalBadge": "Terminal",
|
"terminalBadge": "Terminal",
|
||||||
"tunnelBadge": "Tunnel",
|
"tunnelBadge": "Tunnel",
|
||||||
@@ -460,10 +474,18 @@
|
|||||||
"reconnect": "Reconnect",
|
"reconnect": "Reconnect",
|
||||||
"sessionEnded": "Session Ended",
|
"sessionEnded": "Session Ended",
|
||||||
"connectionLost": "Connection Lost",
|
"connectionLost": "Connection Lost",
|
||||||
"error": "ERROR",
|
"error": "ERROR: {{message}}",
|
||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"connectionClosed": "Connection closed",
|
"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": {
|
"fileManager": {
|
||||||
"title": "File Manager",
|
"title": "File Manager",
|
||||||
@@ -483,6 +505,11 @@
|
|||||||
"clickToSelectFile": "Click to select a file",
|
"clickToSelectFile": "Click to select a file",
|
||||||
"chooseFile": "Choose File",
|
"chooseFile": "Choose File",
|
||||||
"uploading": "Uploading...",
|
"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",
|
"createNewFile": "Create New File",
|
||||||
"fileName": "File Name",
|
"fileName": "File Name",
|
||||||
"creating": "Creating...",
|
"creating": "Creating...",
|
||||||
@@ -652,7 +679,10 @@
|
|||||||
"memoryUsage": "Memory Usage",
|
"memoryUsage": "Memory Usage",
|
||||||
"rootStorageSpace": "Root Storage Space",
|
"rootStorageSpace": "Root Storage Space",
|
||||||
"of": "of",
|
"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": {
|
"auth": {
|
||||||
"loginTitle": "Login to Termix",
|
"loginTitle": "Login to Termix",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
"loadingCredentials": "正在加载凭据...",
|
"loadingCredentials": "正在加载凭据...",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"noCredentials": "暂无凭据",
|
"noCredentials": "暂无凭据",
|
||||||
"noCredentialsMessage": "开始创建您的第一个SSH凭据",
|
"noCredentialsMessage": "你还没有添加任何凭证。点击“添加凭证”以开始。",
|
||||||
"sshCredentials": "SSH凭据",
|
"sshCredentials": "SSH凭据",
|
||||||
"credentialsCount": "{{count}} 个凭据",
|
"credentialsCount": "{{count}} 个凭据",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
@@ -123,7 +123,14 @@
|
|||||||
"editCredentialDescription": "更新凭据信息",
|
"editCredentialDescription": "更新凭据信息",
|
||||||
"listView": "列表",
|
"listView": "列表",
|
||||||
"folderView": "文件夹",
|
"folderView": "文件夹",
|
||||||
"unknown": "未知"
|
"unknown": "未知",
|
||||||
|
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?凭据将被移动到\"未分类\"。",
|
||||||
|
"removedFromFolder": "凭据\"{{name}}\"已成功从文件夹中移除",
|
||||||
|
"failedToRemoveFromFolder": "从文件夹中移除凭据失败",
|
||||||
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
|
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
|
"failedToMoveToFolder": "移动凭据到文件夹失败"
|
||||||
},
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH 工具",
|
"title": "SSH 工具",
|
||||||
@@ -339,6 +346,8 @@
|
|||||||
"importJsonDesc": "上传 JSON 文件以批量导入多个 SSH 主机(最多 100 个)。",
|
"importJsonDesc": "上传 JSON 文件以批量导入多个 SSH 主机(最多 100 个)。",
|
||||||
"downloadSample": "下载示例",
|
"downloadSample": "下载示例",
|
||||||
"formatGuide": "格式指南",
|
"formatGuide": "格式指南",
|
||||||
|
"exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?",
|
||||||
|
"exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?",
|
||||||
"uncategorized": "未分类",
|
"uncategorized": "未分类",
|
||||||
"confirmDelete": "确定要删除 \"{{name}}\" 吗?",
|
"confirmDelete": "确定要删除 \"{{name}}\" 吗?",
|
||||||
"failedToDeleteHost": "删除主机失败",
|
"failedToDeleteHost": "删除主机失败",
|
||||||
@@ -433,6 +442,7 @@
|
|||||||
"selectCredential": "选择凭证",
|
"selectCredential": "选择凭证",
|
||||||
"selectCredentialPlaceholder": "选择一个凭证...",
|
"selectCredentialPlaceholder": "选择一个凭证...",
|
||||||
"credentialRequired": "使用凭证认证时需要选择凭证",
|
"credentialRequired": "使用凭证认证时需要选择凭证",
|
||||||
|
"credentialDescription": "选择凭证将覆盖当前用户名并使用凭证的认证详细信息。",
|
||||||
"sshPrivateKey": "SSH 私钥",
|
"sshPrivateKey": "SSH 私钥",
|
||||||
"keyPassword": "密钥密码",
|
"keyPassword": "密钥密码",
|
||||||
"keyType": "密钥类型",
|
"keyType": "密钥类型",
|
||||||
@@ -466,6 +476,8 @@
|
|||||||
"uploadFile": "上传文件",
|
"uploadFile": "上传文件",
|
||||||
"pasteKey": "粘贴密钥",
|
"pasteKey": "粘贴密钥",
|
||||||
"updateKey": "更新密钥",
|
"updateKey": "更新密钥",
|
||||||
|
"existingKey": "现有密钥(点击更改)",
|
||||||
|
"existingCredential": "现有凭据(点击更改)",
|
||||||
"addTagsSpaceToAdd": "添加标签(空格添加)",
|
"addTagsSpaceToAdd": "添加标签(空格添加)",
|
||||||
"terminalBadge": "终端",
|
"terminalBadge": "终端",
|
||||||
"tunnelBadge": "隧道",
|
"tunnelBadge": "隧道",
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class GitHubCache {
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
expiresAt: now + this.CACHE_DURATION
|
expiresAt: now + this.CACHE_DURATION
|
||||||
});
|
});
|
||||||
// Cache entry set
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): any | null {
|
get(key: string): any | null {
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ router.post('/dismiss', async (req, res) => {
|
|||||||
alertId
|
alertId
|
||||||
});
|
});
|
||||||
|
|
||||||
authLogger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`);
|
|
||||||
res.json({message: 'Alert dismissed successfully'});
|
res.json({message: 'Alert dismissed successfully'});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authLogger.error('Failed to dismiss alert', error);
|
authLogger.error('Failed to dismiss alert', error);
|
||||||
@@ -233,8 +232,6 @@ router.delete('/dismiss', async (req, res) => {
|
|||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({error: 'Dismissed alert not found'});
|
return res.status(404).json({error: 'Dismissed alert not found'});
|
||||||
}
|
}
|
||||||
|
|
||||||
authLogger.success(`Alert ${alertId} undismissed by user ${userId}`);
|
|
||||||
res.json({message: 'Alert undismissed successfully'});
|
res.json({message: 'Alert undismissed successfully'});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authLogger.error('Failed to undismiss alert', error);
|
authLogger.error('Failed to undismiss alert', error);
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
keyType
|
keyType
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
authLogger.info('Credential creation request received', { operation: 'credential_create', userId, name, authType, username });
|
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) {
|
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) {
|
||||||
authLogger.warn('Invalid credential creation data validation failed', { operation: 'credential_create', userId, hasName: !!name, hasUsername: !!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'});
|
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 });
|
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'});
|
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 plainPassword = (authType === 'password' && password) ? password : null;
|
||||||
const plainKey = (authType === 'key' && key) ? key : null;
|
const plainKey = (authType === 'key' && key) ? key : null;
|
||||||
const plainKeyPassword = (authType === 'key' && keyPassword) ? keyPassword : null;
|
const plainKeyPassword = (authType === 'key' && keyPassword) ? keyPassword : null;
|
||||||
@@ -97,10 +93,18 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
lastUsed: null,
|
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 result = await db.insert(sshCredentials).values(credentialData).returning();
|
||||||
const created = result[0];
|
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));
|
res.status(201).json(formatCredentialOutput(created));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error('Failed to create credential in database', err, { operation: 'credential_create', userId, name, authType, username });
|
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)
|
.from(sshCredentials)
|
||||||
.where(eq(sshCredentials.id, parseInt(id)));
|
.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]));
|
res.json(formatCredentialOutput(updated[0]));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error('Failed to update credential', err);
|
authLogger.error('Failed to update credential', err);
|
||||||
@@ -301,6 +315,18 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
const hostsUsingCredential = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(sshData)
|
||||||
@@ -339,6 +365,16 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
eq(sshCredentials.userId, userId)
|
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'});
|
res.json({message: 'Credential deleted successfully'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error('Failed to delete credential', 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;
|
export default router;
|
||||||
@@ -182,6 +182,16 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
|
|
||||||
const resolvedHost = await resolveHostCredentials(baseHost) || baseHost;
|
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);
|
res.json(resolvedHost);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error('Failed to save SSH host to database', err, { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType });
|
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;
|
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);
|
res.json(resolvedHost);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error('Failed to update SSH host in database', err, { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType });
|
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'});
|
return res.status(400).json({error: 'Invalid userId or id'});
|
||||||
}
|
}
|
||||||
try {
|
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)
|
const result = await db.delete(sshData)
|
||||||
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
|
.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'});
|
res.json({message: 'SSH host deleted'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error('Failed to delete SSH host from database', err, { operation: 'host_delete', hostId: parseInt(hostId), userId });
|
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();
|
.returning();
|
||||||
|
|
||||||
sshLogger.success('Folder renamed successfully', {
|
|
||||||
operation: 'folder_rename',
|
|
||||||
userId,
|
|
||||||
oldName,
|
|
||||||
newName,
|
|
||||||
updatedHosts: updatedHosts.length,
|
|
||||||
updatedCredentials: updatedCredentials.length
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Folder renamed successfully',
|
message: 'Folder renamed successfully',
|
||||||
updatedHosts: updatedHosts.length,
|
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;
|
export default router;
|
||||||
@@ -135,8 +135,9 @@ router.post('/create', async (req, res) => {
|
|||||||
if (row && (row as any).value !== 'true') {
|
if (row && (row as any).value !== 'true') {
|
||||||
return res.status(403).json({error: 'Registration is currently disabled'});
|
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;
|
const {username, password} = req.body;
|
||||||
|
|
||||||
@@ -159,19 +160,14 @@ router.post('/create', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
||||||
authLogger.info('Checked user count for admin status', { operation: 'user_create', username, isFirstUser });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
isFirstUser = true;
|
isFirstUser = true;
|
||||||
authLogger.warn('Failed to check user count, assuming first user', { operation: 'user_create', username, error: e });
|
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 saltRounds = parseInt(process.env.SALT || '10', 10);
|
||||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||||
const id = nanoid();
|
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({
|
await db.insert(users).values({
|
||||||
id,
|
id,
|
||||||
username,
|
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 });
|
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) {
|
} catch (err) {
|
||||||
authLogger.error('Failed to create user', err);
|
authLogger.error('Failed to create user', err);
|
||||||
res.status(500).json({error: 'Failed to create user'});
|
res.status(500).json({error: 'Failed to create user'});
|
||||||
@@ -220,27 +216,6 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
|
|||||||
name_path,
|
name_path,
|
||||||
scopes
|
scopes
|
||||||
} = req.body;
|
} = 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) &&
|
const isDisableRequest = (client_id === '' || client_id === null || client_id === undefined) &&
|
||||||
(client_secret === '' || client_secret === null || client_secret === 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(token_url) && isNonEmptyString(identifier_path) &&
|
||||||
isNonEmptyString(name_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) {
|
if (!isDisableRequest && !isEnableRequest) {
|
||||||
authLogger.warn('OIDC validation failed - neither disable nor enable request', {
|
authLogger.warn('OIDC validation failed - neither disable nor enable request', {
|
||||||
operation: 'oidc_config_update',
|
operation: 'oidc_config_update',
|
||||||
@@ -287,7 +239,6 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDisableRequest) {
|
if (isDisableRequest) {
|
||||||
// Disable OIDC by removing the configuration
|
|
||||||
db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run();
|
db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run();
|
||||||
authLogger.info('OIDC configuration disabled', { operation: 'oidc_disable', userId });
|
authLogger.info('OIDC configuration disabled', { operation: 'oidc_disable', userId });
|
||||||
res.json({message: 'OIDC configuration disabled'});
|
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) {
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||||
return res.status(403).json({error: 'Not authorized'});
|
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();
|
db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run();
|
||||||
authLogger.success('OIDC configuration disabled', { operation: 'oidc_disable', userId });
|
authLogger.success('OIDC configuration disabled', { operation: 'oidc_disable', userId });
|
||||||
@@ -480,7 +429,6 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
if (tokenData.id_token) {
|
if (tokenData.id_token) {
|
||||||
try {
|
try {
|
||||||
userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id);
|
userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id);
|
||||||
authLogger.info('Successfully verified ID token and extracted user info');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authLogger.error('OIDC token verification failed, trying userinfo endpoints', error);
|
authLogger.error('OIDC token verification failed, trying userinfo endpoints', error);
|
||||||
try {
|
try {
|
||||||
@@ -488,7 +436,6 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||||
userInfo = payload;
|
userInfo = payload;
|
||||||
authLogger.info('Successfully decoded ID token payload without verification');
|
|
||||||
}
|
}
|
||||||
} catch (decodeError) {
|
} catch (decodeError) {
|
||||||
authLogger.error('Failed to decode ID token payload:', decodeError);
|
authLogger.error('Failed to decode ID token payload:', decodeError);
|
||||||
@@ -586,6 +533,8 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, id));
|
.where(eq(users.id, id));
|
||||||
|
|
||||||
|
// OIDC user created - toast notification handled by frontend
|
||||||
} else {
|
} else {
|
||||||
await db.update(users)
|
await db.update(users)
|
||||||
.set({username: name})
|
.set({username: name})
|
||||||
@@ -595,6 +544,8 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, user[0].id));
|
.where(eq(users.id, user[0].id));
|
||||||
|
|
||||||
|
// OIDC user logged in - toast notification handled by frontend
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRecord = user[0];
|
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'});
|
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);
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||||
if (!isMatch) {
|
if (!isMatch) {
|
||||||
authLogger.warn(`Incorrect password for user: ${username}`, { operation: 'user_login', username, userId: userRecord.id });
|
authLogger.warn(`Incorrect password for user: ${username}`, { operation: 'user_login', username, userId: userRecord.id });
|
||||||
return res.status(401).json({error: 'Incorrect password'});
|
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 jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||||
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {
|
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {
|
||||||
expiresIn: '50d',
|
expiresIn: '50d',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Traditional user logged in - toast notification handled by frontend
|
||||||
|
|
||||||
if (userRecord.totp_enabled) {
|
if (userRecord.totp_enabled) {
|
||||||
authLogger.info('User has TOTP enabled, requiring additional verification', { operation: 'user_login', username, userId: userRecord.id });
|
|
||||||
const tempToken = jwt.sign(
|
const tempToken = jwt.sign(
|
||||||
{userId: userRecord.id, pending_totp: true},
|
{userId: userRecord.id, pending_totp: true},
|
||||||
jwtSecret,
|
jwtSecret,
|
||||||
{expiresIn: '10m'}
|
{expiresIn: '10m'}
|
||||||
);
|
);
|
||||||
authLogger.success('TOTP verification required for login', { operation: 'user_login', username, userId: userRecord.id });
|
|
||||||
return res.json({
|
return res.json({
|
||||||
requires_totp: true,
|
requires_totp: true,
|
||||||
temp_token: tempToken
|
temp_token: tempToken
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
authLogger.success('User login successful', { operation: 'user_login', username, userId: userRecord.id, isAdmin: !!userRecord.is_admin });
|
|
||||||
return res.json({
|
return res.json({
|
||||||
token,
|
token,
|
||||||
is_admin: !!userRecord.is_admin,
|
is_admin: !!userRecord.is_admin,
|
||||||
@@ -1022,6 +968,7 @@ router.post('/make-admin', authenticateJWT, async (req, res) => {
|
|||||||
.where(eq(users.username, username));
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
authLogger.success(`User ${username} made admin by ${adminUser[0].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`});
|
res.json({message: `User ${username} is now an admin`});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1064,6 +1011,7 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => {
|
|||||||
.where(eq(users.username, username));
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
authLogger.success(`Admin status removed from ${username} by ${adminUser[0].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}`});
|
res.json({message: `Admin status removed from ${username}`});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1125,6 +1073,8 @@ router.post('/totp/verify-login', async (req, res) => {
|
|||||||
expiresIn: '50d',
|
expiresIn: '50d',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TOTP login completed - toast notification handled by frontend
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
token,
|
token,
|
||||||
is_admin: !!userRecord.is_admin,
|
is_admin: !!userRecord.is_admin,
|
||||||
@@ -1224,6 +1174,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
// 2FA enabled - toast notification handled by frontend
|
||||||
res.json({
|
res.json({
|
||||||
message: 'TOTP enabled successfully',
|
message: 'TOTP enabled successfully',
|
||||||
backup_codes: backupCodes
|
backup_codes: backupCodes
|
||||||
@@ -1285,6 +1236,7 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
// 2FA disabled - toast notification handled by frontend
|
||||||
res.json({message: 'TOTP disabled successfully'});
|
res.json({message: 'TOTP disabled successfully'});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1401,6 +1353,7 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => {
|
|||||||
await db.delete(users).where(eq(users.id, targetUserId));
|
await db.delete(users).where(eq(users.id, targetUserId));
|
||||||
|
|
||||||
authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`);
|
authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`);
|
||||||
|
// User deleted - toast notification handled by frontend
|
||||||
res.json({message: `User ${username} deleted successfully`});
|
res.json({message: `User ${username} deleted successfully`});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -307,8 +307,8 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
app.post('/ssh/file_manager/ssh/writeFile', async (req, res) => {
|
||||||
const {sessionId, path: filePath, content} = req.body;
|
const {sessionId, path: filePath, content, hostId, userId} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -371,7 +371,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
if (!res.headersSent) {
|
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;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
if (!res.headersSent) {
|
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) => {
|
stream.on('close', (code) => {
|
||||||
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
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}`}});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`);
|
fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -462,8 +462,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
trySFTP();
|
trySFTP();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
app.post('/ssh/file_manager/ssh/uploadFile', async (req, res) => {
|
||||||
const {sessionId, path: filePath, content, fileName} = req.body;
|
const {sessionId, path: filePath, content, fileName, hostId, userId} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -527,7 +527,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
if (!res.headersSent) {
|
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;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
if (!res.headersSent) {
|
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')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
fileLogger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
|
||||||
if (!res.headersSent) {
|
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 {
|
} else {
|
||||||
fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`);
|
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')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
fileLogger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
|
||||||
if (!res.headersSent) {
|
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 {
|
} else {
|
||||||
fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
|
fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
|
||||||
@@ -686,8 +684,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
trySFTP();
|
trySFTP();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
app.post('/ssh/file_manager/ssh/createFile', async (req, res) => {
|
||||||
const {sessionId, path: filePath, fileName, content = ''} = req.body;
|
const {sessionId, path: filePath, fileName, content = '', hostId, userId} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -742,7 +740,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -756,7 +754,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
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) => {
|
app.post('/ssh/file_manager/ssh/createFolder', async (req, res) => {
|
||||||
const {sessionId, path: folderPath, folderName} = req.body;
|
const {sessionId, path: folderPath, folderName, hostId, userId} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -826,7 +824,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -840,7 +838,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
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) => {
|
app.delete('/ssh/file_manager/ssh/deleteItem', async (req, res) => {
|
||||||
const {sessionId, path: itemPath, isDirectory} = req.body;
|
const {sessionId, path: itemPath, isDirectory, hostId, userId} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -909,7 +907,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -923,7 +921,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
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) => {
|
app.put('/ssh/file_manager/ssh/renameItem', async (req, res) => {
|
||||||
const {sessionId, oldPath, newName} = req.body;
|
const {sessionId, oldPath, newName, hostId, userId} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -994,7 +992,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1006,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
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}`}});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
let sshConn: Client | null = null;
|
let sshConn: Client | null = null;
|
||||||
let sshStream: ClientChannel | null = null;
|
let sshStream: ClientChannel | null = null;
|
||||||
let pingInterval: NodeJS.Timeout | null = null;
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
sshLogger.info('New WebSocket connection established', { operation: 'websocket_connect' });
|
|
||||||
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
@@ -40,7 +38,6 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'connectToHost':
|
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 => {
|
handleConnectToHost(data).catch(error => {
|
||||||
sshLogger.error('Failed to connect to host', error, { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip });
|
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')}));
|
ws.send(JSON.stringify({type: 'error', message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')}));
|
||||||
|
|||||||
@@ -382,12 +382,7 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
|
|||||||
const tunnelName = tunnelConfig.name;
|
const tunnelName = tunnelConfig.name;
|
||||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
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)) {
|
if (manualDisconnects.has(tunnelName)) {
|
||||||
tunnelLogger.info('Tunnel connection cancelled due to manual disconnect', { operation: 'tunnel_connect', tunnelName });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,23 @@ import './ssh/terminal.js';
|
|||||||
import './ssh/tunnel.js';
|
import './ssh/tunnel.js';
|
||||||
import './ssh/file-manager.js';
|
import './ssh/file-manager.js';
|
||||||
import './ssh/server-stats.js';
|
import './ssh/server-stats.js';
|
||||||
import { systemLogger } from './utils/logger.js';
|
import { systemLogger, versionLogger } from './utils/logger.js';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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.info("Initializing backend services...", { operation: 'startup' });
|
||||||
|
|
||||||
systemLogger.success("All backend services initialized successfully", {
|
systemLogger.success("All backend services initialized successfully", {
|
||||||
operation: 'startup_complete',
|
operation: 'startup_complete',
|
||||||
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats']
|
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'],
|
||||||
|
version: version
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
|
|||||||
@@ -158,13 +158,14 @@ class Logger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const databaseLogger = new Logger('DATABASE', '🗄️', '#1e3a8a');
|
export const databaseLogger = new Logger('DATABASE', '🗄️', '#6366f1');
|
||||||
export const sshLogger = new Logger('SSH', '🖥️', '#1e3a8a');
|
export const sshLogger = new Logger('SSH', '🖥️', '#0ea5e9');
|
||||||
export const tunnelLogger = new Logger('TUNNEL', '📡', '#1e3a8a');
|
export const tunnelLogger = new Logger('TUNNEL', '📡', '#a855f7');
|
||||||
export const fileLogger = new Logger('FILE', '📁', '#1e3a8a');
|
export const fileLogger = new Logger('FILE', '📁', '#f59e0b');
|
||||||
export const statsLogger = new Logger('STATS', '📊', '#22c55e');
|
export const statsLogger = new Logger('STATS', '📊', '#22c55e');
|
||||||
export const apiLogger = new Logger('API', '🌐', '#3b82f6');
|
export const apiLogger = new Logger('API', '🌐', '#3b82f6');
|
||||||
export const authLogger = new Logger('AUTH', '🔐', '#dc2626');
|
export const authLogger = new Logger('AUTH', '🔐', '#ef4444');
|
||||||
export const systemLogger = new Logger('SYSTEM', '🚀', '#1e3a8a');
|
export const systemLogger = new Logger('SYSTEM', '🚀', '#14b8a6');
|
||||||
|
export const versionLogger = new Logger('VERSION', '📦', '#8b5cf6');
|
||||||
|
|
||||||
export const logger = systemLogger;
|
export const logger = systemLogger;
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import type { Credential } from '../types/index.js';
|
|||||||
interface CredentialSelectorProps {
|
interface CredentialSelectorProps {
|
||||||
value?: number | null;
|
value?: number | null;
|
||||||
onValueChange: (credentialId: number | null) => void;
|
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 { t } = useTranslation();
|
||||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -26,11 +27,12 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getCredentials();
|
const data = await getCredentials();
|
||||||
// Handle both possible response formats: direct array or nested object
|
|
||||||
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []);
|
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []);
|
||||||
setCredentials(credentialsArray);
|
setCredentials(credentialsArray);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch credentials:', error);
|
console.error('Failed to fetch credentials:', error);
|
||||||
|
const {toast} = await import('sonner');
|
||||||
|
toast.error(t('credentials.failedToFetchCredentials'));
|
||||||
setCredentials([]);
|
setCredentials([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -77,12 +79,18 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
|
|||||||
|
|
||||||
const handleCredentialSelect = (credential: Credential) => {
|
const handleCredentialSelect = (credential: Credential) => {
|
||||||
onValueChange(credential.id);
|
onValueChange(credential.id);
|
||||||
|
if (onCredentialSelect) {
|
||||||
|
onCredentialSelect(credential);
|
||||||
|
}
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
onValueChange(null);
|
onValueChange(null);
|
||||||
|
if (onCredentialSelect) {
|
||||||
|
onCredentialSelect(null);
|
||||||
|
}
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
};
|
};
|
||||||
@@ -101,6 +109,12 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
t('common.loading')
|
t('common.loading')
|
||||||
|
) : value === "existing_credential" ? (
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{t('hosts.existingCredential')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : selectedCredential ? (
|
) : selectedCredential ? (
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div>
|
<div>
|
||||||
@@ -132,7 +146,7 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-60 overflow-y-auto p-1">
|
<div className="max-h-60 overflow-y-auto p-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||||
{t('common.loading')}
|
{t('common.loading')}
|
||||||
@@ -142,13 +156,13 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
|
|||||||
{searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')}
|
{searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-1">
|
<div className="grid grid-cols-1 gap-2.5">
|
||||||
{value && (
|
{value && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start text-left rounded-lg px-2 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
>
|
>
|
||||||
{t('common.clear')}
|
{t('common.clear')}
|
||||||
@@ -160,7 +174,7 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`w-full justify-start text-left rounded-lg px-2 py-2 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
|
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
|
||||||
credential.id === value ? 'bg-muted' : ''
|
credential.id === value ? 'bg-muted' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleCredentialSelect(credential)}
|
onClick={() => handleCredentialSelect(credential)}
|
||||||
@@ -168,11 +182,6 @@ export function CredentialSelector({ value, onValueChange }: CredentialSelectorP
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium">{credential.name}</span>
|
<span className="font-medium">{credential.name}</span>
|
||||||
{credential.folder && (
|
|
||||||
<span className="text-xs bg-muted px-1 rounded">
|
|
||||||
{credential.folder}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
{credential.username} • {credential.authType}
|
{credential.username} • {credential.authType}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import {Shield, Trash2, Users} from "lucide-react";
|
import {Shield, Trash2, Users} from "lucide-react";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {useConfirmation} from "@/hooks/use-confirmation.ts";
|
||||||
import {
|
import {
|
||||||
getOIDCConfig,
|
getOIDCConfig,
|
||||||
getRegistrationAllowed,
|
getRegistrationAllowed,
|
||||||
@@ -43,6 +44,7 @@ interface AdminSettingsProps {
|
|||||||
|
|
||||||
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const {confirmWithToast} = useConfirmation();
|
||||||
const {state: sidebarState} = useSidebar();
|
const {state: sidebarState} = useSidebar();
|
||||||
|
|
||||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||||
@@ -80,7 +82,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
.then(res => {
|
.then(res => {
|
||||||
if (res) setOidcConfig(res);
|
if (res) setOidcConfig(res);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
|
console.error('Failed to fetch OIDC config:', err);
|
||||||
|
toast.error(t('admin.failedToFetchOidcConfig'));
|
||||||
});
|
});
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -92,7 +96,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
setAllowRegistration(res.allowed);
|
setAllowRegistration(res.allowed);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
|
console.error('Failed to fetch registration status:', err);
|
||||||
|
toast.error(t('admin.failedToFetchRegistrationStatus'));
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -166,29 +172,38 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveAdminStatus = async (username: string) => {
|
const handleRemoveAdminStatus = async (username: string) => {
|
||||||
if (!confirm(t('admin.removeAdminStatus', { username }))) return;
|
confirmWithToast(
|
||||||
const jwt = getCookie("jwt");
|
t('admin.removeAdminStatus', { username }),
|
||||||
try {
|
async () => {
|
||||||
await removeAdminStatus(username);
|
const jwt = getCookie("jwt");
|
||||||
toast.success(t('admin.adminStatusRemoved', { username }));
|
try {
|
||||||
fetchUsers();
|
await removeAdminStatus(username);
|
||||||
} catch (err: any) {
|
toast.success(t('admin.adminStatusRemoved', { username }));
|
||||||
console.error('Failed to remove admin status:', err);
|
fetchUsers();
|
||||||
toast.error(t('admin.failedToRemoveAdminStatus'));
|
} catch (err: any) {
|
||||||
}
|
console.error('Failed to remove admin status:', err);
|
||||||
|
toast.error(t('admin.failedToRemoveAdminStatus'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteUser = async (username: string) => {
|
const handleDeleteUser = async (username: string) => {
|
||||||
if (!confirm(t('admin.deleteUser', { username }))) return;
|
confirmWithToast(
|
||||||
const jwt = getCookie("jwt");
|
t('admin.deleteUser', { username }),
|
||||||
try {
|
async () => {
|
||||||
await deleteUser(username);
|
const jwt = getCookie("jwt");
|
||||||
toast.success(t('admin.userDeletedSuccessfully', { username }));
|
try {
|
||||||
fetchUsers();
|
await deleteUser(username);
|
||||||
} catch (err: any) {
|
toast.success(t('admin.userDeletedSuccessfully', { username }));
|
||||||
console.error('Failed to delete user:', err);
|
fetchUsers();
|
||||||
toast.error(t('admin.failedToDeleteUser'));
|
} catch (err: any) {
|
||||||
}
|
console.error('Failed to delete user:', err);
|
||||||
|
toast.error(t('admin.failedToDeleteUser'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'destructive'
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios'
|
import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios'
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import type { Credential, CredentialEditorProps, CredentialData } from '../../../types/index.js'
|
import type { Credential, CredentialEditorProps, CredentialData } from '../../../../types/index.js'
|
||||||
|
|
||||||
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
|
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -120,12 +120,12 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(formSchema) as any,
|
resolver: zodResolver(formSchema) as any,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: editingCredential?.name || "",
|
name: "",
|
||||||
description: editingCredential?.description || "",
|
description: "",
|
||||||
folder: editingCredential?.folder || "",
|
folder: "",
|
||||||
tags: editingCredential?.tags || [],
|
tags: [],
|
||||||
authType: editingCredential?.authType || "password",
|
authType: "password",
|
||||||
username: editingCredential?.username || "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
@@ -138,18 +138,33 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
const defaultAuthType = fullCredentialDetails.authType;
|
const defaultAuthType = fullCredentialDetails.authType;
|
||||||
setAuthTab(defaultAuthType);
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
form.reset({
|
// Force form reset with a small delay to ensure proper rendering
|
||||||
name: fullCredentialDetails.name || "",
|
setTimeout(() => {
|
||||||
description: fullCredentialDetails.description || "",
|
const formData = {
|
||||||
folder: fullCredentialDetails.folder || "",
|
name: fullCredentialDetails.name || "",
|
||||||
tags: fullCredentialDetails.tags || [],
|
description: fullCredentialDetails.description || "",
|
||||||
authType: defaultAuthType as 'password' | 'key',
|
folder: fullCredentialDetails.folder || "",
|
||||||
username: fullCredentialDetails.username || "",
|
tags: fullCredentialDetails.tags || [],
|
||||||
password: fullCredentialDetails.password || "",
|
authType: defaultAuthType as 'password' | 'key',
|
||||||
key: null,
|
username: fullCredentialDetails.username || "",
|
||||||
keyPassword: fullCredentialDetails.keyPassword || "",
|
password: "",
|
||||||
keyType: (fullCredentialDetails.keyType as any) || "auto",
|
key: null,
|
||||||
});
|
keyPassword: "",
|
||||||
|
keyType: "auto" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only set the relevant authentication fields based on authType
|
||||||
|
if (defaultAuthType === 'password') {
|
||||||
|
formData.password = fullCredentialDetails.password || "";
|
||||||
|
} else if (defaultAuthType === 'key') {
|
||||||
|
formData.key = "existing_key"; // Placeholder to indicate existing key
|
||||||
|
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||||
|
formData.keyType = (fullCredentialDetails.keyType as any) || "auto" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(formData);
|
||||||
|
setTagInput("");
|
||||||
|
}, 100);
|
||||||
} else if (!editingCredential) {
|
} else if (!editingCredential) {
|
||||||
setAuthTab('password');
|
setAuthTab('password');
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -164,8 +179,9 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
});
|
});
|
||||||
|
setTagInput("");
|
||||||
}
|
}
|
||||||
}, [editingCredential?.id, fullCredentialDetails]);
|
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
try {
|
try {
|
||||||
@@ -183,14 +199,24 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
keyType: data.keyType
|
keyType: data.keyType
|
||||||
};
|
};
|
||||||
|
|
||||||
|
submitData.password = null;
|
||||||
|
submitData.key = null;
|
||||||
|
submitData.keyPassword = null;
|
||||||
|
submitData.keyType = null;
|
||||||
|
|
||||||
if (data.authType === 'password') {
|
if (data.authType === 'password') {
|
||||||
submitData.password = data.password;
|
submitData.password = data.password;
|
||||||
submitData.key = undefined;
|
|
||||||
submitData.keyPassword = undefined;
|
|
||||||
} else if (data.authType === 'key') {
|
} else if (data.authType === 'key') {
|
||||||
submitData.key = data.key instanceof File ? await data.key.text() : data.key;
|
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;
|
||||||
|
}
|
||||||
submitData.keyPassword = data.keyPassword;
|
submitData.keyPassword = data.keyPassword;
|
||||||
submitData.password = undefined;
|
submitData.keyType = data.keyType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingCredential) {
|
if (editingCredential) {
|
||||||
@@ -206,6 +232,9 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||||
|
|
||||||
|
// Reset form after successful submission
|
||||||
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t('credentials.failedToSaveCredential'));
|
toast.error(t('credentials.failedToSaveCredential'));
|
||||||
}
|
}
|
||||||
@@ -285,7 +314,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
}, [keyTypeDropdownOpen]);
|
}, [keyTypeDropdownOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
|
<div className="flex-1 flex flex-col h-full min-h-0 w-full" key={editingCredential?.id || 'new'}>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
|
||||||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||||
@@ -392,15 +421,17 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div
|
<div
|
||||||
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
|
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
|
||||||
{field.value.map((tag: string, idx: number) => (
|
{(field.value || []).map((tag: string, idx: number) => (
|
||||||
<span key={tag + idx}
|
<span key={`${tag}-${idx}`}
|
||||||
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
|
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
|
||||||
{tag}
|
{tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
|
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
const newTags = field.value.filter((_: string, i: number) => i !== idx);
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const newTags = (field.value || []).filter((_: string, i: number) => i !== idx);
|
||||||
field.onChange(newTags);
|
field.onChange(newTags);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -410,18 +441,27 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
))}
|
))}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6"
|
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm"
|
||||||
value={tagInput}
|
value={tagInput}
|
||||||
onChange={e => setTagInput(e.target.value)}
|
onChange={e => setTagInput(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === " " && tagInput.trim() !== "") {
|
if (e.key === " " && tagInput.trim() !== "") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!field.value.includes(tagInput.trim())) {
|
const currentTags = field.value || [];
|
||||||
field.onChange([...field.value, tagInput.trim()]);
|
if (!currentTags.includes(tagInput.trim())) {
|
||||||
|
field.onChange([...currentTags, tagInput.trim()]);
|
||||||
}
|
}
|
||||||
setTagInput("");
|
setTagInput("");
|
||||||
} else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) {
|
} else if (e.key === "Enter" && tagInput.trim() !== "") {
|
||||||
field.onChange(field.value.slice(0, -1));
|
e.preventDefault();
|
||||||
|
const currentTags = field.value || [];
|
||||||
|
if (!currentTags.includes(tagInput.trim())) {
|
||||||
|
field.onChange([...currentTags, tagInput.trim()]);
|
||||||
|
}
|
||||||
|
setTagInput("");
|
||||||
|
} else if (e.key === "Backspace" && tagInput === "" && (field.value || []).length > 0) {
|
||||||
|
const currentTags = field.value || [];
|
||||||
|
field.onChange(currentTags.slice(0, -1));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={t('credentials.addTagsSpaceToAdd')}
|
placeholder={t('credentials.addTagsSpaceToAdd')}
|
||||||
@@ -442,13 +482,17 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
setAuthTab(newAuthType);
|
setAuthTab(newAuthType);
|
||||||
form.setValue('authType', newAuthType);
|
form.setValue('authType', newAuthType);
|
||||||
|
|
||||||
// Clear other auth fields when switching
|
// Clear ALL authentication fields first
|
||||||
|
form.setValue('password', '');
|
||||||
|
form.setValue('key', null);
|
||||||
|
form.setValue('keyPassword', '');
|
||||||
|
form.setValue('keyType', 'auto');
|
||||||
|
|
||||||
|
// Then set only the relevant fields based on auth type
|
||||||
if (newAuthType === 'password') {
|
if (newAuthType === 'password') {
|
||||||
form.setValue('key', null);
|
// Password fields will be filled by user
|
||||||
form.setValue('keyPassword', '');
|
|
||||||
form.setValue('keyType', 'auto');
|
|
||||||
} else if (newAuthType === 'key') {
|
} else if (newAuthType === 'key') {
|
||||||
form.setValue('password', '');
|
// Key fields will be filled by user
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 flex flex-col h-full min-h-0"
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
@@ -490,40 +534,41 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
|
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="upload" className="mt-4">
|
<TabsContent value="upload" className="mt-4">
|
||||||
<div className="grid grid-cols-15 gap-4">
|
<Controller
|
||||||
<Controller
|
control={form.control}
|
||||||
control={form.control}
|
name="key"
|
||||||
name="key"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem className="mb-4">
|
||||||
<FormItem className="col-span-4 overflow-hidden min-w-0">
|
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
|
||||||
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<div className="relative inline-block">
|
||||||
<div className="relative min-w-0">
|
<input
|
||||||
<input
|
id="key-upload"
|
||||||
id="key-upload"
|
type="file"
|
||||||
type="file"
|
accept=".pem,.key,.txt,.ppk"
|
||||||
accept=".pem,.key,.txt,.ppk"
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
const file = e.target.files?.[0];
|
||||||
const file = e.target.files?.[0];
|
field.onChange(file || null);
|
||||||
field.onChange(file || null);
|
}}
|
||||||
}}
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
/>
|
||||||
/>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="outline"
|
className="justify-start text-left"
|
||||||
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
|
>
|
||||||
>
|
<span className="truncate"
|
||||||
<span className="block w-full truncate"
|
title={field.value?.name || t('credentials.upload')}>
|
||||||
title={field.value?.name || t('credentials.upload')}>
|
{field.value === "existing_key" ? t('hosts.existingKey') :
|
||||||
{field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
|
field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="keyPassword"
|
name="keyPassword"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -14,22 +14,34 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Pin,
|
Pin,
|
||||||
Tag,
|
Tag,
|
||||||
Info
|
Info,
|
||||||
|
FolderMinus,
|
||||||
|
Pencil,
|
||||||
|
X,
|
||||||
|
Check
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getCredentials, deleteCredential } from '@/ui/main-axios';
|
import { getCredentials, deleteCredential, updateCredential, renameCredentialFolder } from '@/ui/main-axios';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useConfirmation } from '@/hooks/use-confirmation.ts';
|
||||||
import CredentialViewer from './CredentialViewer';
|
import CredentialViewer from './CredentialViewer';
|
||||||
import type { Credential, CredentialsManagerProps } from '../../../types/index.js';
|
import type { Credential, CredentialsManagerProps } from '../../../../types/index.js';
|
||||||
|
|
||||||
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { confirmWithToast } = useConfirmation();
|
||||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showViewer, setShowViewer] = useState(false);
|
const [showViewer, setShowViewer] = useState(false);
|
||||||
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
|
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
|
||||||
|
const [draggedCredential, setDraggedCredential] = useState<Credential | null>(null);
|
||||||
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||||
|
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||||
|
const [editingFolderName, setEditingFolderName] = useState("");
|
||||||
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCredentials();
|
fetchCredentials();
|
||||||
@@ -58,19 +70,139 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
|||||||
|
|
||||||
|
|
||||||
const handleDelete = async (credentialId: number, credentialName: string) => {
|
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||||
if (window.confirm(t('credentials.confirmDeleteCredential', { name: credentialName }))) {
|
confirmWithToast(
|
||||||
try {
|
t('credentials.confirmDeleteCredential', { name: credentialName }),
|
||||||
await deleteCredential(credentialId);
|
async () => {
|
||||||
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
try {
|
||||||
await fetchCredentials();
|
await deleteCredential(credentialId);
|
||||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
||||||
} catch (err: any) {
|
await fetchCredentials();
|
||||||
if (err.response?.data?.details) {
|
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||||
toast.error(`${err.response.data.error}\n${err.response.data.details}`);
|
} catch (err: any) {
|
||||||
} else {
|
if (err.response?.data?.details) {
|
||||||
toast.error(t('credentials.failedToDeleteCredential'));
|
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) {
|
if (credentials.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="text-center">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-2">{t('credentials.noCredentials')}</h3>
|
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground">
|
||||||
{t('credentials.noCredentialsMessage')}
|
{t('credentials.credentialsCount', { count: 0 })}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||||
|
{t('credentials.refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center flex-1">
|
||||||
|
<div className="text-center">
|
||||||
|
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{t('credentials.noCredentials')}</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
{t('credentials.noCredentialsMessage')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -191,14 +339,90 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
|||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
<div className="space-y-2 pb-20">
|
<div className="space-y-2 pb-20">
|
||||||
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
|
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
|
||||||
<div key={folder} className="border rounded-md">
|
<div
|
||||||
|
key={folder}
|
||||||
|
className={`border rounded-md transition-all duration-200 ${
|
||||||
|
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : ''
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={(e) => handleDragEnter(e, folder)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, folder)}
|
||||||
|
>
|
||||||
<Accordion type="multiple" defaultValue={Object.keys(credentialsByFolder)}>
|
<Accordion type="multiple" defaultValue={Object.keys(credentialsByFolder)}>
|
||||||
<AccordionItem value={folder} className="border-none">
|
<AccordionItem value={folder} className="border-none">
|
||||||
<AccordionTrigger
|
<AccordionTrigger
|
||||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<Folder className="h-4 w-4"/>
|
<Folder className="h-4 w-4"/>
|
||||||
<span className="font-medium">{folder}</span>
|
{editingFolder === folder ? (
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Input
|
||||||
|
value={editingFolderName}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleFolderRename(folder);
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
cancelFolderEdit();
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (folder !== t('credentials.uncategorized')) {
|
||||||
|
startFolderEdit(folder);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={folder !== t('credentials.uncategorized') ? 'Click to rename folder' : ''}
|
||||||
|
>
|
||||||
|
{folder}
|
||||||
|
</span>
|
||||||
|
{folder !== t('credentials.uncategorized') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startFolderEdit(folder);
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||||
|
title="Rename folder"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{folderCredentials.length}
|
{folderCredentials.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -207,87 +431,138 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
|||||||
<AccordionContent className="p-2">
|
<AccordionContent className="p-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
{folderCredentials.map((credential) => (
|
{folderCredentials.map((credential) => (
|
||||||
<div
|
<TooltipProvider key={credential.id}>
|
||||||
key={credential.id}
|
<Tooltip>
|
||||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => handleEdit(credential)}
|
<div
|
||||||
>
|
draggable
|
||||||
<div className="flex items-start justify-between">
|
onDragStart={(e) => handleDragStart(e, credential)}
|
||||||
<div className="flex-1 min-w-0">
|
onDragEnd={handleDragEnd}
|
||||||
<div className="flex items-center gap-1">
|
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 ${
|
||||||
<h3 className="font-medium truncate text-sm">
|
draggedCredential?.id === credential.id ? 'opacity-50 scale-95' : ''
|
||||||
{credential.name || `${credential.username}`}
|
}`}
|
||||||
</h3>
|
onClick={() => handleEdit(credential)}
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{credential.username}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEdit(credential);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 p-0"
|
|
||||||
>
|
>
|
||||||
<Edit className="h-3 w-3"/>
|
<div className="flex items-start justify-between">
|
||||||
</Button>
|
<div className="flex-1 min-w-0">
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
size="sm"
|
<h3 className="font-medium truncate text-sm">
|
||||||
variant="ghost"
|
{credential.name || `${credential.username}`}
|
||||||
onClick={(e) => {
|
</h3>
|
||||||
e.stopPropagation();
|
</div>
|
||||||
handleDelete(credential.id, credential.name || credential.username);
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
}}
|
{credential.username}
|
||||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
</p>
|
||||||
>
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
<Trash2 className="h-3 w-3"/>
|
{credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')}
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||||
|
{credential.folder && credential.folder !== '' && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveFromFolder(credential);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
<FolderMinus className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Remove from folder "{credential.folder}"</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(credential);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Edit credential</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(credential.id, credential.name || credential.username);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Delete credential</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{credential.tags && credential.tags.length > 0 && (
|
{credential.tags && credential.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{credential.tags.slice(0, 6).map((tag, index) => (
|
{credential.tags.slice(0, 6).map((tag, index) => (
|
||||||
<Badge key={index} variant="outline"
|
<Badge key={index} variant="outline"
|
||||||
className="text-xs px-1 py-0">
|
className="text-xs px-1 py-0">
|
||||||
<Tag className="h-2 w-2 mr-0.5"/>
|
<Tag className="h-2 w-2 mr-0.5"/>
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{credential.tags.length > 6 && (
|
{credential.tags.length > 6 && (
|
||||||
<Badge variant="outline"
|
<Badge variant="outline"
|
||||||
className="text-xs px-1 py-0">
|
className="text-xs px-1 py-0">
|
||||||
+{credential.tags.length - 6}
|
+{credential.tags.length - 6}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||||
|
{credential.authType === 'password' ? (
|
||||||
|
<Key className="h-2 w-2 mr-0.5"/>
|
||||||
|
) : (
|
||||||
|
<Shield className="h-2 w-2 mr-0.5"/>
|
||||||
|
)}
|
||||||
|
{credential.authType}
|
||||||
|
</Badge>
|
||||||
|
{credential.authType === 'key' && credential.keyType && (
|
||||||
|
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||||
|
{credential.keyType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="text-center">
|
||||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
<p className="font-medium">Click to edit credential</p>
|
||||||
{credential.authType === 'password' ? (
|
<p className="text-xs text-muted-foreground">Drag to move between folders</p>
|
||||||
<Key className="h-2 w-2 mr-0.5"/>
|
</div>
|
||||||
) : (
|
</TooltipContent>
|
||||||
<Shield className="h-2 w-2 mr-0.5"/>
|
</Tooltip>
|
||||||
)}
|
</TooltipProvider>
|
||||||
{credential.authType}
|
|
||||||
</Badge>
|
|
||||||
{credential.authType === 'key' && credential.keyType && (
|
|
||||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
|
||||||
{credential.keyType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
|
|||||||
@@ -377,7 +377,12 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
loading: false
|
loading: false
|
||||||
} : t));
|
} : 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([
|
Promise.allSettled([
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -390,12 +395,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
hostId: currentHost.id
|
hostId: currentHost.id
|
||||||
});
|
});
|
||||||
} catch (recentErr) {
|
} catch (recentErr) {
|
||||||
|
console.error('Failed to add recent file:', recentErr);
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await fetchHomeData();
|
await fetchHomeData();
|
||||||
} catch (refreshErr) {
|
} catch (refreshErr) {
|
||||||
|
console.error('Failed to refresh home data:', refreshErr);
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
@@ -451,8 +458,15 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
const response = await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
||||||
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`);
|
|
||||||
|
// 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);
|
setDeletingItem(null);
|
||||||
handleOperationComplete();
|
handleOperationComplete();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -71,16 +71,33 @@ export function FileManagerOperations({
|
|||||||
if (!uploadFile || !sshSessionId) return;
|
if (!uploadFile || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Show loading toast
|
||||||
|
const {toast} = await import('sonner');
|
||||||
|
const loadingToast = toast.loading(t('fileManager.uploadingFile', { name: uploadFile.name }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await uploadFile.text();
|
const content = await uploadFile.text();
|
||||||
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
const response = await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
||||||
onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name }));
|
|
||||||
|
// 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);
|
setShowUpload(false);
|
||||||
setUploadFile(null);
|
setUploadFile(null);
|
||||||
onOperationComplete();
|
onOperationComplete();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Dismiss loading toast and show error
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile'));
|
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -91,15 +108,32 @@ export function FileManagerOperations({
|
|||||||
if (!newFileName.trim() || !sshSessionId) return;
|
if (!newFileName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Show loading toast
|
||||||
|
const {toast} = await import('sonner');
|
||||||
|
const loadingToast = toast.loading(t('fileManager.creatingFile', { name: newFileName.trim() }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
const response = await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
||||||
onSuccess(t('fileManager.fileCreatedSuccessfully', { name: 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);
|
setShowCreateFile(false);
|
||||||
setNewFileName('');
|
setNewFileName('');
|
||||||
onOperationComplete();
|
onOperationComplete();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Dismiss loading toast and show error
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile'));
|
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -110,15 +144,32 @@ export function FileManagerOperations({
|
|||||||
if (!newFolderName.trim() || !sshSessionId) return;
|
if (!newFolderName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Show loading toast
|
||||||
|
const {toast} = await import('sonner');
|
||||||
|
const loadingToast = toast.loading(t('fileManager.creatingFolder', { name: newFolderName.trim() }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
const response = await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
||||||
onSuccess(t('fileManager.folderCreatedSuccessfully', { name: 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);
|
setShowCreateFolder(false);
|
||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
onOperationComplete();
|
onOperationComplete();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Dismiss loading toast and show error
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder'));
|
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -129,16 +180,36 @@ export function FileManagerOperations({
|
|||||||
if (!deletePath || !sshSessionId) return;
|
if (!deletePath || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
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 {
|
try {
|
||||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
const response = await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
||||||
onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
|
||||||
|
// 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);
|
setShowDelete(false);
|
||||||
setDeletePath('');
|
setDeletePath('');
|
||||||
setDeleteIsDirectory(false);
|
setDeleteIsDirectory(false);
|
||||||
onOperationComplete();
|
onOperationComplete();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Dismiss loading toast and show error
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -149,17 +220,38 @@ export function FileManagerOperations({
|
|||||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
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 {
|
try {
|
||||||
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
const response = await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
||||||
onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
|
||||||
|
// 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);
|
setShowRename(false);
|
||||||
setRenamePath('');
|
setRenamePath('');
|
||||||
setRenameIsDirectory(false);
|
setRenameIsDirectory(false);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
onOperationComplete();
|
onOperationComplete();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Dismiss loading toast and show error
|
||||||
|
toast.dismiss(loadingToast);
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
|
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
||||||
if (updatedHost) {
|
setEditingHost(null);
|
||||||
setEditingHost(updatedHost);
|
|
||||||
} else {
|
|
||||||
setEditingHost(null);
|
|
||||||
}
|
|
||||||
setActiveTab("host_viewer");
|
setActiveTab("host_viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,10 +40,11 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
|||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
if (value === "host_viewer") {
|
// Reset editing states when switching away from edit tabs
|
||||||
|
if (value !== "add_host") {
|
||||||
setEditingHost(null);
|
setEditingHost(null);
|
||||||
}
|
}
|
||||||
if (value === "credentials") {
|
if (value !== "add_credential") {
|
||||||
setEditingCredential(null);
|
setEditingCredential(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import React, {useEffect, useRef, useState} from "react";
|
|||||||
import {Switch} from "@/components/ui/switch.tsx";
|
import {Switch} from "@/components/ui/switch.tsx";
|
||||||
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||||
import {toast} from "sonner";
|
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 {useTranslation} from "react-i18next";
|
||||||
import {CredentialSelector} from "@/components/CredentialSelector.tsx";
|
import {CredentialSelector} from "@/components/CredentialSelector.tsx";
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||||
|
const [credentials, setCredentials] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
|
const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
|
||||||
@@ -71,8 +72,12 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const hostsData = await getSSHHosts();
|
const [hostsData, credentialsData] = await Promise.all([
|
||||||
|
getSSHHosts(),
|
||||||
|
getCredentials()
|
||||||
|
]);
|
||||||
setHosts(hostsData);
|
setHosts(hostsData);
|
||||||
|
setCredentials(credentialsData);
|
||||||
|
|
||||||
const uniqueFolders = [...new Set(
|
const uniqueFolders = [...new Set(
|
||||||
hostsData
|
hostsData
|
||||||
@@ -97,6 +102,43 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
fetchData();
|
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({
|
const formSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
ip: z.string().min(1),
|
ip: z.string().min(1),
|
||||||
@@ -143,7 +185,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (data.authType === 'key') {
|
} else if (data.authType === 'key') {
|
||||||
if (!data.key) {
|
if (!data.key || (typeof data.key === 'string' && data.key.trim() === '')) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: t('hosts.sshKeyRequired'),
|
message: t('hosts.sshKeyRequired'),
|
||||||
@@ -158,7 +200,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (data.authType === 'credential') {
|
} else if (data.authType === 'credential') {
|
||||||
if (!data.credentialId) {
|
if (!data.credentialId || (typeof data.credentialId === 'string' && data.credentialId.trim() === '')) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: t('hosts.credentialRequired'),
|
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(() => {
|
useEffect(() => {
|
||||||
if (editingHost) {
|
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);
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
name: editingHost.name || "",
|
name: cleanedHost.name || "",
|
||||||
ip: editingHost.ip || "",
|
ip: cleanedHost.ip || "",
|
||||||
port: editingHost.port || 22,
|
port: cleanedHost.port || 22,
|
||||||
username: editingHost.username || "",
|
username: cleanedHost.username || "",
|
||||||
folder: editingHost.folder || "",
|
folder: cleanedHost.folder || "",
|
||||||
tags: editingHost.tags || [],
|
tags: cleanedHost.tags || [],
|
||||||
pin: Boolean(editingHost.pin),
|
pin: Boolean(cleanedHost.pin),
|
||||||
authType: defaultAuthType as 'password' | 'key' | 'credential',
|
authType: defaultAuthType as 'password' | 'key' | 'credential',
|
||||||
credentialId: editingHost.credentialId || null,
|
credentialId: null,
|
||||||
password: editingHost.password || "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: editingHost.keyPassword || "",
|
keyPassword: "",
|
||||||
keyType: (editingHost.keyType as any) || "auto",
|
keyType: "auto" as const,
|
||||||
enableTerminal: Boolean(editingHost.enableTerminal),
|
enableTerminal: Boolean(cleanedHost.enableTerminal),
|
||||||
enableTunnel: Boolean(editingHost.enableTunnel),
|
enableTunnel: Boolean(cleanedHost.enableTunnel),
|
||||||
enableFileManager: Boolean(editingHost.enableFileManager),
|
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
||||||
defaultPath: editingHost.defaultPath || "/",
|
defaultPath: cleanedHost.defaultPath || "/",
|
||||||
tunnelConnections: editingHost.tunnelConnections || [],
|
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);
|
form.reset(formData);
|
||||||
} else {
|
} else {
|
||||||
@@ -292,24 +369,26 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
tunnelConnections: data.tunnelConnections || []
|
tunnelConnections: data.tunnelConnections || []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
submitData.credentialId = null;
|
||||||
|
submitData.password = null;
|
||||||
|
submitData.key = null;
|
||||||
|
submitData.keyPassword = null;
|
||||||
|
submitData.keyType = null;
|
||||||
|
|
||||||
if (data.authType === 'credential') {
|
if (data.authType === 'credential') {
|
||||||
submitData.credentialId = data.credentialId;
|
if (data.credentialId === "existing_credential") {
|
||||||
submitData.password = null;
|
delete submitData.credentialId;
|
||||||
submitData.key = null;
|
} else {
|
||||||
submitData.keyPassword = null;
|
submitData.credentialId = data.credentialId;
|
||||||
submitData.keyType = null;
|
}
|
||||||
} else if (data.authType === 'password') {
|
} else if (data.authType === 'password') {
|
||||||
submitData.credentialId = null;
|
|
||||||
submitData.password = data.password;
|
submitData.password = data.password;
|
||||||
submitData.key = null;
|
|
||||||
submitData.keyPassword = null;
|
|
||||||
submitData.keyType = null;
|
|
||||||
} else if (data.authType === 'key') {
|
} else if (data.authType === 'key') {
|
||||||
submitData.credentialId = null;
|
|
||||||
submitData.password = null;
|
|
||||||
if (data.key instanceof File) {
|
if (data.key instanceof File) {
|
||||||
const keyContent = await data.key.text();
|
const keyContent = await data.key.text();
|
||||||
submitData.key = keyContent;
|
submitData.key = keyContent;
|
||||||
|
} else if (data.key === "existing_key") {
|
||||||
|
delete submitData.key;
|
||||||
} else {
|
} else {
|
||||||
submitData.key = data.key;
|
submitData.key = data.key;
|
||||||
}
|
}
|
||||||
@@ -334,6 +413,9 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
|
||||||
|
// Reset form after successful submission
|
||||||
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t('hosts.failedToSaveHost'));
|
toast.error(t('hosts.failedToSaveHost'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -663,7 +745,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
setAuthTab(newAuthType);
|
setAuthTab(newAuthType);
|
||||||
form.setValue('authType', 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') {
|
if (newAuthType === 'password') {
|
||||||
form.setValue('key', null);
|
form.setValue('key', null);
|
||||||
form.setValue('keyPassword', '');
|
form.setValue('keyPassword', '');
|
||||||
@@ -744,7 +826,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
>
|
>
|
||||||
<span className="truncate"
|
<span className="truncate"
|
||||||
title={field.value?.name || t('hosts.upload')}>
|
title={field.value?.name || t('hosts.upload')}>
|
||||||
{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')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -843,10 +926,21 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="credentialId"
|
name="credentialId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<CredentialSelector
|
<FormItem>
|
||||||
value={field.value}
|
<CredentialSelector
|
||||||
onValueChange={field.onChange}
|
value={field.value}
|
||||||
/>
|
onValueChange={field.onChange}
|
||||||
|
onCredentialSelect={(credential) => {
|
||||||
|
if (credential) {
|
||||||
|
// Update username when credential is selected
|
||||||
|
form.setValue('username', credential.username);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormDescription>
|
||||||
|
{t('hosts.credentialDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/compon
|
|||||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder} from "@/ui/main-axios.ts";
|
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder} from "@/ui/main-axios.ts";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {useConfirmation} from "@/hooks/use-confirmation.ts";
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -24,13 +25,15 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
Pencil
|
Pencil,
|
||||||
|
FolderMinus
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
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) {
|
export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const {confirmWithToast} = useConfirmation();
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -66,7 +69,25 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getSSHHosts();
|
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);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(t('hosts.failedToLoadHosts'));
|
setError(t('hosts.failedToLoadHosts'));
|
||||||
@@ -76,47 +97,92 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (hostId: number, hostName: string) => {
|
const handleDelete = async (hostId: number, hostName: string) => {
|
||||||
if (window.confirm(t('hosts.confirmDelete', { name: hostName }))) {
|
confirmWithToast(
|
||||||
try {
|
t('hosts.confirmDelete', { name: hostName }),
|
||||||
await deleteSSHHost(hostId);
|
async () => {
|
||||||
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
|
try {
|
||||||
await fetchHosts();
|
await deleteSSHHost(hostId);
|
||||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
|
||||||
} catch (err) {
|
await fetchHosts();
|
||||||
toast.error(t('hosts.failedToDeleteHost'));
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
}
|
} catch (err) {
|
||||||
}
|
toast.error(t('hosts.failedToDeleteHost'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'destructive'
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = (host: SSHHost) => {
|
const handleExport = (host: SSHHost) => {
|
||||||
const exportData = {
|
const actualAuthType = host.credentialId ? 'credential' : (host.key ? 'key' : 'password');
|
||||||
name: host.name,
|
|
||||||
ip: host.ip,
|
// Check if host uses sensitive authentication data
|
||||||
port: host.port,
|
if (actualAuthType === 'credential') {
|
||||||
username: host.username,
|
const confirmMessage = t('hosts.exportCredentialWarning', {
|
||||||
authType: host.authType,
|
name: host.name || `${host.username}@${host.ip}`
|
||||||
folder: host.folder,
|
});
|
||||||
tags: host.tags,
|
|
||||||
pin: host.pin,
|
confirmWithToast(confirmMessage, () => {
|
||||||
enableTerminal: host.enableTerminal,
|
performExport(host, actualAuthType);
|
||||||
enableTunnel: host.enableTunnel,
|
});
|
||||||
enableFileManager: host.enableFileManager,
|
return;
|
||||||
defaultPath: host.defaultPath,
|
} else if (actualAuthType === 'password' || actualAuthType === 'key') {
|
||||||
tunnelConnections: host.tunnelConnections,
|
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 performExport = (host: SSHHost, actualAuthType: string) => {
|
||||||
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);
|
|
||||||
|
|
||||||
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) => {
|
const handleEdit = (host: SSHHost) => {
|
||||||
@@ -126,20 +192,23 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFromFolder = async (host: SSHHost) => {
|
const handleRemoveFromFolder = async (host: SSHHost) => {
|
||||||
if (window.confirm(t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }))) {
|
confirmWithToast(
|
||||||
try {
|
t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }),
|
||||||
setOperationLoading(true);
|
async () => {
|
||||||
const updatedHost = { ...host, folder: '' };
|
try {
|
||||||
await updateSSHHost(host.id, updatedHost);
|
setOperationLoading(true);
|
||||||
toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` }));
|
const updatedHost = { ...host, folder: '' };
|
||||||
await fetchHosts();
|
await updateSSHHost(host.id, updatedHost);
|
||||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` }));
|
||||||
} catch (err) {
|
await fetchHosts();
|
||||||
toast.error(t('hosts.failedToRemoveFromFolder'));
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
} finally {
|
} catch (err) {
|
||||||
setOperationLoading(false);
|
toast.error(t('hosts.failedToRemoveFromFolder'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFolderRename = async (oldName: string) => {
|
const handleFolderRename = async (oldName: string) => {
|
||||||
@@ -400,51 +469,66 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const sampleData = {
|
const sampleData = {
|
||||||
hosts: [
|
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",
|
sourcePort: 5432,
|
||||||
ip: "192.168.1.100",
|
endpointPort: 5432,
|
||||||
port: 22,
|
endpointHost: "Web Server - Production",
|
||||||
username: "admin",
|
maxRetries: 3,
|
||||||
authType: "password",
|
retryInterval: 10,
|
||||||
password: "your_secure_password_here",
|
autoStart: true
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
},
|
||||||
|
{
|
||||||
|
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 blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -478,6 +562,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="json-import-input"
|
id="json-import-input"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -493,9 +578,6 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{t('hosts.noHostsMessage')}
|
{t('hosts.noHostsMessage')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('hosts.getStartedMessage', { defaultValue: 'Use the Import JSON button above to add hosts from a JSON file.' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -583,6 +665,21 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
autoStart: true
|
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) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="json-import-input"
|
id="json-import-input"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -732,25 +830,27 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
<AccordionContent className="p-2">
|
<AccordionContent className="p-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
{folderHosts.map((host) => (
|
{folderHosts.map((host) => (
|
||||||
<div
|
<TooltipProvider key={host.id}>
|
||||||
key={host.id}
|
<Tooltip>
|
||||||
draggable
|
<TooltipTrigger asChild>
|
||||||
onDragStart={(e) => handleDragStart(e, host)}
|
<div
|
||||||
onDragEnd={handleDragEnd}
|
draggable
|
||||||
className={`bg-[#222225] border border-input rounded cursor-move hover:shadow-md transition-all p-2 ${
|
onDragStart={(e) => handleDragStart(e, host)}
|
||||||
draggedHost?.id === host.id ? 'opacity-50 scale-95' : ''
|
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 ${
|
||||||
onClick={() => handleEdit(host)}
|
draggedHost?.id === host.id ? 'opacity-50 scale-95' : ''
|
||||||
>
|
}`}
|
||||||
<div className="flex items-start justify-between">
|
onClick={() => handleEdit(host)}
|
||||||
<div className="flex-1 min-w-0">
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-start justify-between">
|
||||||
{host.pin && <Pin
|
<div className="flex-1 min-w-0">
|
||||||
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
<div className="flex items-center gap-1">
|
||||||
<h3 className="font-medium truncate text-sm">
|
{host.pin && <Pin
|
||||||
{host.name || `${host.username}@${host.ip}`}
|
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
||||||
</h3>
|
<h3 className="font-medium truncate text-sm">
|
||||||
</div>
|
{host.name || `${host.username}@${host.ip}`}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{host.ip}:{host.port}
|
{host.ip}:{host.port}
|
||||||
</p>
|
</p>
|
||||||
@@ -760,53 +860,80 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||||
{host.folder && host.folder !== '' && (
|
{host.folder && host.folder !== '' && (
|
||||||
<Button
|
<Tooltip>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
variant="ghost"
|
||||||
handleRemoveFromFolder(host);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700"
|
handleRemoveFromFolder(host);
|
||||||
title={`Remove from folder "${host.folder}"`}
|
}}
|
||||||
disabled={operationLoading}
|
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
|
||||||
>
|
disabled={operationLoading}
|
||||||
<X className="h-3 w-3"/>
|
>
|
||||||
</Button>
|
<FolderMinus className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Remove from folder "{host.folder}"</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Tooltip>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
variant="ghost"
|
||||||
handleEdit(host);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
className="h-5 w-5 p-0"
|
handleEdit(host);
|
||||||
>
|
}}
|
||||||
<Edit className="h-3 w-3"/>
|
className="h-5 w-5 p-0 hover:bg-blue-500/10"
|
||||||
</Button>
|
>
|
||||||
<Button
|
<Edit className="h-3 w-3"/>
|
||||||
size="sm"
|
</Button>
|
||||||
variant="ghost"
|
</TooltipTrigger>
|
||||||
onClick={(e) => {
|
<TooltipContent>
|
||||||
e.stopPropagation();
|
<p>Edit host</p>
|
||||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
</TooltipContent>
|
||||||
}}
|
</Tooltip>
|
||||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
<Tooltip>
|
||||||
>
|
<TooltipTrigger asChild>
|
||||||
<Trash2 className="h-3 w-3"/>
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
<Button
|
variant="ghost"
|
||||||
size="sm"
|
onClick={(e) => {
|
||||||
variant="ghost"
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||||
e.stopPropagation();
|
}}
|
||||||
handleExport(host);
|
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||||
}}
|
>
|
||||||
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700"
|
<Trash2 className="h-3 w-3"/>
|
||||||
>
|
</Button>
|
||||||
<Upload className="h-3 w-3"/>
|
</TooltipTrigger>
|
||||||
</Button>
|
<TooltipContent>
|
||||||
|
<p>Delete host</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleExport(host);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<Upload className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Export host</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -856,6 +983,15 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-medium">Click to edit host</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Drag to move between folders</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {Tunnel} from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
|||||||
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
||||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
interface ServerProps {
|
interface ServerProps {
|
||||||
hostConfig?: any;
|
hostConfig?: any;
|
||||||
@@ -47,6 +48,8 @@ export function Server({
|
|||||||
setCurrentHostConfig(updatedHost);
|
setCurrentHostConfig(updatedHost);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
setCurrentHostConfig(updatedHost);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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) {
|
if (!cancelled) {
|
||||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (!cancelled) setServerStatus('offline');
|
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 {
|
try {
|
||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
if (!cancelled) setMetrics(data);
|
if (!cancelled) setMetrics(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (!cancelled) setMetrics(null);
|
console.error('Failed to fetch server metrics:', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setMetrics(null);
|
||||||
|
toast.error(t('serverStats.failedToFetchMetrics'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {ClipboardAddon} from '@xterm/addon-clipboard';
|
|||||||
import {Unicode11Addon} from '@xterm/addon-unicode11';
|
import {Unicode11Addon} from '@xterm/addon-unicode11';
|
||||||
import {WebLinksAddon} from '@xterm/addon-web-links';
|
import {WebLinksAddon} from '@xterm/addon-web-links';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
interface SSHTerminalProps {
|
interface SSHTerminalProps {
|
||||||
hostConfig: any;
|
hostConfig: any;
|
||||||
@@ -26,7 +27,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
const wasDisconnectedBySSH = useRef(false);
|
const wasDisconnectedBySSH = useRef(false);
|
||||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const reconnectAttempts = useRef(0);
|
||||||
|
const maxReconnectAttempts = 3;
|
||||||
|
|
||||||
|
|
||||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
@@ -69,7 +75,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
clearInterval(pingIntervalRef.current);
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null;
|
pingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
webSocketRef.current?.close();
|
webSocketRef.current?.close();
|
||||||
|
setIsConnected(false);
|
||||||
},
|
},
|
||||||
fit: () => {
|
fit: () => {
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
@@ -118,10 +129,51 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
return getCookie("rightClickCopyPaste") === "true"
|
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) {
|
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||||
ws.addEventListener('open', () => {
|
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}}));
|
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
@@ -133,32 +185,72 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
ws.send(JSON.stringify({type: 'ping'}));
|
ws.send(JSON.stringify({type: 'ping'}));
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('message', (event) => {
|
ws.addEventListener('message', (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
if (msg.type === 'data') terminal.write(msg.data);
|
if (msg.type === 'data') {
|
||||||
else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`);
|
terminal.write(msg.data);
|
||||||
else if (msg.type === 'connected') {
|
} 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') {
|
} else if (msg.type === 'disconnected') {
|
||||||
wasDisconnectedBySSH.current = true;
|
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) {
|
} catch (error) {
|
||||||
|
toast.error(t('terminal.messageParseError'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
ws.addEventListener('close', (event) => {
|
||||||
|
setIsConnected(false);
|
||||||
if (!wasDisconnectedBySSH.current) {
|
if (!wasDisconnectedBySSH.current) {
|
||||||
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
|
toast.warning(t('terminal.connectionClosed'));
|
||||||
|
// Attempt reconnection for unexpected disconnections
|
||||||
|
attemptReconnection();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('error', () => {
|
ws.addEventListener('error', (event) => {
|
||||||
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
|
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<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
? 'ws://127.0.0.1:8082'
|
? 'ws://127.0.0.1:8082'
|
||||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
connectToHost(cols, rows);
|
||||||
webSocketRef.current = ws;
|
|
||||||
wasDisconnectedBySSH.current = false;
|
|
||||||
|
|
||||||
setupWebSocketListeners(ws, cols, rows);
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,6 +389,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
element?.removeEventListener('contextmenu', handleContextMenu);
|
element?.removeEventListener('contextmenu', handleContextMenu);
|
||||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
|
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current);
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null;
|
pingIntervalRef.current = null;
|
||||||
@@ -341,16 +430,29 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
}, [splitScreen, isVisible, terminal]);
|
}, [splitScreen, isVisible, terminal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="h-full w-full relative">
|
||||||
ref={xtermRef}
|
{/* Connection Status Indicator */}
|
||||||
className="h-full w-full m-1"
|
{!isConnected && (
|
||||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
<div className="absolute top-2 right-2 z-10 bg-red-500 text-white px-2 py-1 rounded text-xs">
|
||||||
onClick={() => {
|
{t('terminal.disconnected')}
|
||||||
if (terminal && !splitScreen) {
|
</div>
|
||||||
terminal.focus();
|
)}
|
||||||
}
|
{isConnected && (
|
||||||
}}
|
<div className="absolute top-2 right-2 z-10 bg-green-500 text-white px-2 py-1 rounded text-xs">
|
||||||
/>
|
{t('terminal.connected')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={xtermRef}
|
||||||
|
className="h-full w-full m-1"
|
||||||
|
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||||
|
onClick={() => {
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(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 isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||||
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
|
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
|
||||||
const openSshManagerTab = () => {
|
const openSshManagerTab = () => {
|
||||||
@@ -171,7 +171,17 @@ export function LeftSidebar({
|
|||||||
newHost.username !== existingHost.username ||
|
newHost.username !== existingHost.username ||
|
||||||
newHost.pin !== existingHost.pin ||
|
newHost.pin !== existingHost.pin ||
|
||||||
newHost.enableTerminal !== existingHost.enableTerminal ||
|
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;
|
hasChanges = true;
|
||||||
break;
|
break;
|
||||||
@@ -183,12 +193,17 @@ export function LeftSidebar({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setHosts(newHosts);
|
setHosts(newHosts);
|
||||||
prevHostsRef.current = newHosts;
|
prevHostsRef.current = newHosts;
|
||||||
|
|
||||||
|
// Update hostConfig in existing tabs
|
||||||
|
newHosts.forEach(newHost => {
|
||||||
|
updateHostConfig(newHost.id, newHost);
|
||||||
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setHostsError(t('leftSidebar.failedToLoadHosts'));
|
setHostsError(t('leftSidebar.failedToLoadHosts'));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [updateHostConfig]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
@@ -200,8 +215,15 @@ export function LeftSidebar({
|
|||||||
const handleHostsChanged = () => {
|
const handleHostsChanged = () => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
};
|
};
|
||||||
|
const handleCredentialsChanged = () => {
|
||||||
|
fetchHosts();
|
||||||
|
};
|
||||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
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]);
|
}, [fetchHosts]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface TabContextType {
|
|||||||
setCurrentTab: (tabId: number) => void;
|
setCurrentTab: (tabId: number) => void;
|
||||||
setSplitScreenTab: (tabId: number) => void;
|
setSplitScreenTab: (tabId: number) => void;
|
||||||
getTab: (tabId: number) => Tab | undefined;
|
getTab: (tabId: number) => Tab | undefined;
|
||||||
|
updateHostConfig: (hostId: number, newHostConfig: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||||
@@ -111,6 +112,19 @@ export function TabProvider({children}: TabProviderProps) {
|
|||||||
return tabs.find(tab => tab.id === tabId);
|
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 = {
|
const value: TabContextType = {
|
||||||
tabs,
|
tabs,
|
||||||
currentTab,
|
currentTab,
|
||||||
@@ -120,6 +134,7 @@ export function TabProvider({children}: TabProviderProps) {
|
|||||||
setCurrentTab,
|
setCurrentTab,
|
||||||
setSplitScreenTab,
|
setSplitScreenTab,
|
||||||
getTab,
|
getTab,
|
||||||
|
updateHostConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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<any> {
|
export async function writeSSHFile(sessionId: string, path: string, content: string, hostId?: number, userId?: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/writeFile', {
|
const response = await fileManagerApi.post('/ssh/writeFile', {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
content
|
content,
|
||||||
|
hostId,
|
||||||
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
|
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<any> {
|
export async function uploadSSHFile(sessionId: string, path: string, fileName: string, content: string, hostId?: number, userId?: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/uploadFile', {
|
const response = await fileManagerApi.post('/ssh/uploadFile', {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
fileName,
|
fileName,
|
||||||
content
|
content,
|
||||||
|
hostId,
|
||||||
|
userId
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} 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<any> {
|
export async function createSSHFile(sessionId: string, path: string, fileName: string, content: string = '', hostId?: number, userId?: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/createFile', {
|
const response = await fileManagerApi.post('/ssh/createFile', {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
fileName,
|
fileName,
|
||||||
content
|
content,
|
||||||
|
hostId,
|
||||||
|
userId
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} 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<any> {
|
export async function createSSHFolder(sessionId: string, path: string, folderName: string, hostId?: number, userId?: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/createFolder', {
|
const response = await fileManagerApi.post('/ssh/createFolder', {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
folderName
|
folderName,
|
||||||
|
hostId,
|
||||||
|
userId
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} 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<any> {
|
export async function deleteSSHItem(sessionId: string, path: string, isDirectory: boolean, hostId?: number, userId?: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.delete('/ssh/deleteItem', {
|
const response = await fileManagerApi.delete('/ssh/deleteItem', {
|
||||||
data: {
|
data: {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
isDirectory
|
isDirectory,
|
||||||
|
hostId,
|
||||||
|
userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return response.data;
|
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<any> {
|
export async function renameSSHItem(sessionId: string, oldPath: string, newName: string, hostId?: number, userId?: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.put('/ssh/renameItem', {
|
const response = await fileManagerApi.put('/ssh/renameItem', {
|
||||||
sessionId,
|
sessionId,
|
||||||
oldPath,
|
oldPath,
|
||||||
newName
|
newName,
|
||||||
|
hostId,
|
||||||
|
userId
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1276,4 +1288,16 @@ export async function renameFolder(oldName: string, newName: string): Promise<an
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'rename folder');
|
handleApiError(error, 'rename folder');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameCredentialFolder(oldName: string, newName: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.put('/credentials/folders/rename', {
|
||||||
|
oldName,
|
||||||
|
newName
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'rename credential folder');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user