Update file base
This commit is contained in:
25
.claude/settings.local.json
Normal file
25
.claude/settings.local.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read(/C:\\Users\\29037\\WebstormProjects\\Termix\\docker/**)",
|
||||||
|
"Bash(git fetch:*)",
|
||||||
|
"Bash(git pull:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(npm install)",
|
||||||
|
"Bash(npm run electron:build:*)",
|
||||||
|
"Bash(npm uninstall:*)",
|
||||||
|
"Bash(git remote set-url:*)",
|
||||||
|
"Bash(npm run dev:backend:*)",
|
||||||
|
"Bash(taskkill:*)",
|
||||||
|
"Bash(node:*)",
|
||||||
|
"WebFetch(domain:ui.shadcn.com)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.env
4
.env
@@ -1 +1,3 @@
|
|||||||
VERSION=1.5.0
|
VERSION=1.6.0
|
||||||
|
VITE_API_HOST=localhost
|
||||||
|
CREDENTIAL_ENCRYPTION_KEY=98fbfabe84b125db7cbbb5168eb584aaecc2f3779a2aaa955c57bdd305071a84
|
||||||
|
|||||||
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help Termix improve
|
||||||
|
title: "[BUG]"
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots/Logs**
|
||||||
|
If applicable, add screenshots or console/Docker logs to help explain your problem.
|
||||||
|
|
||||||
|
**Environment (please complete the following information):**
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 1.6.0]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for Termix
|
||||||
|
title: "[FEATURE]"
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
/db/
|
/db/
|
||||||
|
/release/
|
||||||
|
|||||||
100
README-CN.md
Normal file
100
README-CN.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Repo Stats
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> |
|
||||||
|
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
|
||||||
|
</p>
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||||
|
#### Top Technologies
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/LukeGus/Termix">
|
||||||
|
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
如果你愿意,可以在这里支持这个项目!\
|
||||||
|
[](https://github.com/sponsors/LukeGus)
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/LukeGus/Termix">
|
||||||
|
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix 提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
|
||||||
|
|
||||||
|
# Features
|
||||||
|
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
|
||||||
|
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控
|
||||||
|
- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等)
|
||||||
|
- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹
|
||||||
|
- **服务器统计** - 查看任意 SSH 服务器的 CPU、内存和硬盘使用情况
|
||||||
|
- **用户认证** - 安全的用户管理,支持管理员控制、OIDC 和双因素认证(TOTP)
|
||||||
|
- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面
|
||||||
|
- **语言支持** - 内置中英文支持
|
||||||
|
|
||||||
|
# Planned Features
|
||||||
|
- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能
|
||||||
|
- **主题定制** - 修改所有工具的主题风格
|
||||||
|
- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP(有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我)
|
||||||
|
- **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
termix:
|
||||||
|
image: ghcr.io/lukegus/termix:latest
|
||||||
|
container_name: termix
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- termix-data:/app/data
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
termix-data:
|
||||||
|
driver: local
|
||||||
|
```
|
||||||
|
|
||||||
|
# Support
|
||||||
|
如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。
|
||||||
|
|
||||||
|
# Show-off
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
<img src="./repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
|
||||||
|
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
|
||||||
|
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
|
||||||
|
你的浏览器不支持 video 标签。
|
||||||
|
</video>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# License
|
||||||
|
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。
|
||||||
|
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
# Repo Stats
|
# Repo Stats
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English |
|
||||||
|
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|||||||
50
electron-builder.json
Normal file
50
electron-builder.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.termix.app",
|
||||||
|
"productName": "Termix",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*"
|
||||||
|
],
|
||||||
|
"extraMetadata": {
|
||||||
|
"main": "electron/main-simple.cjs"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.developer-tools",
|
||||||
|
"icon": "public/icon.icns",
|
||||||
|
"hardenedRuntime": true,
|
||||||
|
"gatekeeperAssess": false,
|
||||||
|
"entitlements": "build/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "dmg",
|
||||||
|
"arch": ["x64", "arm64"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "zip",
|
||||||
|
"arch": ["x64", "arm64"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"icon": "public/icon.ico"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"artifactName": "${productName}-Setup-${version}.${ext}"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"category": "Development",
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "AppImage",
|
||||||
|
"arch": ["x64"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
178
electron/main-simple.cjs
Normal file
178
electron/main-simple.cjs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
const { app, BrowserWindow, Menu, shell, ipcMain } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
// 全局变量
|
||||||
|
let mainWindow = null;
|
||||||
|
let backendProcess = null;
|
||||||
|
|
||||||
|
// 开发环境检测
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||||
|
|
||||||
|
// 启动后端服务
|
||||||
|
function startBackendServer() {
|
||||||
|
if (backendProcess) {
|
||||||
|
console.log('Backend server already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendPath = path.join(__dirname, '../dist/backend/starter.js');
|
||||||
|
console.log('Starting backend server from:', backendPath);
|
||||||
|
|
||||||
|
backendProcess = spawn('node', [backendPath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
cwd: path.join(__dirname, '..') // Set working directory to app root
|
||||||
|
});
|
||||||
|
|
||||||
|
backendProcess.stdout.on('data', (data) => {
|
||||||
|
console.log('Backend:', data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
backendProcess.stderr.on('data', (data) => {
|
||||||
|
console.error('Backend Error:', data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
backendProcess.on('close', (code) => {
|
||||||
|
console.log(`Backend process exited with code ${code}`);
|
||||||
|
backendProcess = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止后端服务
|
||||||
|
function stopBackendServer() {
|
||||||
|
if (backendProcess) {
|
||||||
|
console.log('Stopping backend server...');
|
||||||
|
backendProcess.kill();
|
||||||
|
backendProcess = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止多开
|
||||||
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
if (!gotTheLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建主窗口
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
title: 'Termix',
|
||||||
|
icon: path.join(__dirname, '..', 'public', 'icon.png'),
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, 'preload-simple.cjs'),
|
||||||
|
webSecurity: !isDev
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建应用菜单(包含开发者工具快捷键)
|
||||||
|
const template = [
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Toggle Developer Tools',
|
||||||
|
accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
|
||||||
|
click: () => {
|
||||||
|
mainWindow.webContents.toggleDevTools();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reload',
|
||||||
|
accelerator: 'CmdOrCtrl+R',
|
||||||
|
click: () => {
|
||||||
|
mainWindow.webContents.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate(template);
|
||||||
|
Menu.setApplicationMenu(menu);
|
||||||
|
|
||||||
|
// 加载应用
|
||||||
|
if (isDev) {
|
||||||
|
// 开发环境:连接到 Vite 开发服务器
|
||||||
|
mainWindow.loadURL('http://localhost:5173');
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
// 生产环境:加载构建后的文件
|
||||||
|
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||||
|
// 生产环境也启用开发者工具以便调试
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口准备好后显示
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
mainWindow.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理窗口关闭事件
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理外部链接
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC 通信处理
|
||||||
|
ipcMain.handle('get-app-version', () => {
|
||||||
|
return app.getVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-platform', () => {
|
||||||
|
return process.platform;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应用事件处理
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// 在生产环境启动后端服务
|
||||||
|
if (!isDev) {
|
||||||
|
startBackendServer();
|
||||||
|
}
|
||||||
|
createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
stopBackendServer();
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
} else if (mainWindow) {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理未捕获的异常
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('Uncaught Exception:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||||
|
});
|
||||||
467
electron/main.cjs
Normal file
467
electron/main.cjs
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
const { app, BrowserWindow, Menu, Tray, shell, ipcMain, dialog } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// 动态导入可能有 ESM 问题的模块
|
||||||
|
let portfinder;
|
||||||
|
let Store;
|
||||||
|
let autoUpdater;
|
||||||
|
|
||||||
|
try {
|
||||||
|
portfinder = require('portfinder');
|
||||||
|
Store = require('electron-store');
|
||||||
|
const updaterModule = require('electron-updater');
|
||||||
|
autoUpdater = updaterModule.autoUpdater;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading modules:', error);
|
||||||
|
// 提供后备方案
|
||||||
|
portfinder = {
|
||||||
|
getPortPromise: async () => 18080 + Math.floor(Math.random() * 100)
|
||||||
|
};
|
||||||
|
Store = class {
|
||||||
|
constructor() { this.data = {}; }
|
||||||
|
get(key, defaultValue) { return this.data[key] || defaultValue; }
|
||||||
|
set(key, value) { this.data[key] = value; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化配置存储
|
||||||
|
const store = new Store();
|
||||||
|
|
||||||
|
// 全局变量
|
||||||
|
let mainWindow = null;
|
||||||
|
let backendProcess = null;
|
||||||
|
let tray = null;
|
||||||
|
let backendPort = null;
|
||||||
|
let isQuitting = false;
|
||||||
|
|
||||||
|
// 开发环境检测
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||||
|
|
||||||
|
// 防止多开
|
||||||
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
if (!gotTheLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
// 如果用户试图运行第二个实例,我们应该聚焦我们的窗口
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端进程管理类
|
||||||
|
class BackendManager {
|
||||||
|
constructor() {
|
||||||
|
this.process = null;
|
||||||
|
this.port = null;
|
||||||
|
this.retryCount = 0;
|
||||||
|
this.maxRetries = 3;
|
||||||
|
this.isStarting = false;
|
||||||
|
this.healthCheckInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAvailablePort() {
|
||||||
|
portfinder.basePort = store.get('backend.port', 18080);
|
||||||
|
try {
|
||||||
|
const port = await portfinder.getPortPromise();
|
||||||
|
this.port = port;
|
||||||
|
return port;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding available port:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (this.isStarting || this.process) {
|
||||||
|
console.log('Backend already starting or running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 查找可用端口
|
||||||
|
await this.findAvailablePort();
|
||||||
|
console.log(`Starting backend on port ${this.port}`);
|
||||||
|
|
||||||
|
// 确定后端可执行文件路径
|
||||||
|
let backendPath;
|
||||||
|
if (isDev) {
|
||||||
|
// 开发环境:使用 node 运行构建后的 JS
|
||||||
|
backendPath = path.join(__dirname, '..', 'dist', 'backend', 'starter.js');
|
||||||
|
} else {
|
||||||
|
// 生产环境:使用打包后的后端
|
||||||
|
backendPath = path.join(process.resourcesPath, 'backend', 'starter.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保后端文件存在
|
||||||
|
if (!fs.existsSync(backendPath)) {
|
||||||
|
throw new Error(`Backend file not found at ${backendPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置环境变量
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
PORT: this.port.toString(),
|
||||||
|
NODE_ENV: isDev ? 'development' : 'production',
|
||||||
|
DATA_PATH: app.getPath('userData'),
|
||||||
|
DB_PATH: path.join(app.getPath('userData'), 'database.db'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动后端进程
|
||||||
|
if (isDev) {
|
||||||
|
this.process = spawn('node', [backendPath], {
|
||||||
|
env,
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.process = spawn('node', [backendPath], {
|
||||||
|
env,
|
||||||
|
cwd: process.resourcesPath,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听后端输出
|
||||||
|
this.process.stdout.on('data', (data) => {
|
||||||
|
console.log(`Backend stdout: ${data}`);
|
||||||
|
// 向渲染进程发送日志
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('backend-log', data.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr.on('data', (data) => {
|
||||||
|
console.error(`Backend stderr: ${data}`);
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('backend-error', data.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听后端进程退出
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
console.log(`Backend process exited with code ${code}`);
|
||||||
|
this.process = null;
|
||||||
|
this.isStarting = false;
|
||||||
|
|
||||||
|
// 如果不是正在退出且退出码不为0,尝试重启
|
||||||
|
if (!isQuitting && code !== 0 && this.retryCount < this.maxRetries) {
|
||||||
|
this.retryCount++;
|
||||||
|
console.log(`Attempting to restart backend (retry ${this.retryCount}/${this.maxRetries})`);
|
||||||
|
setTimeout(() => this.start(), 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待后端启动
|
||||||
|
await this.waitForBackend();
|
||||||
|
|
||||||
|
// 启动健康检查
|
||||||
|
this.startHealthCheck();
|
||||||
|
|
||||||
|
// 更新全局端口变量
|
||||||
|
backendPort = this.port;
|
||||||
|
|
||||||
|
// 通知渲染进程
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('backend-started', { port: this.port });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarting = false;
|
||||||
|
this.retryCount = 0;
|
||||||
|
|
||||||
|
return this.port;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start backend:', error);
|
||||||
|
this.isStarting = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForBackend() {
|
||||||
|
const maxWaitTime = 30000; // 30秒
|
||||||
|
const checkInterval = 500; // 每500ms检查一次
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
|
try {
|
||||||
|
// 尝试连接后端健康检查端点
|
||||||
|
const response = await fetch(`http://127.0.0.1:${this.port}/health`);
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Backend is ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 继续等待
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Backend failed to start within timeout period');
|
||||||
|
}
|
||||||
|
|
||||||
|
startHealthCheck() {
|
||||||
|
if (this.healthCheckInterval) {
|
||||||
|
clearInterval(this.healthCheckInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthCheckInterval = setInterval(async () => {
|
||||||
|
if (!this.process) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${this.port}/health`);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Backend health check failed');
|
||||||
|
// 可以在这里触发重启逻辑
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backend health check error:', error);
|
||||||
|
}
|
||||||
|
}, 10000); // 每10秒检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.healthCheckInterval) {
|
||||||
|
clearInterval(this.healthCheckInterval);
|
||||||
|
this.healthCheckInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.process) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Stopping backend process...');
|
||||||
|
|
||||||
|
// 设置超时强制杀死
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
if (this.process) {
|
||||||
|
console.log('Force killing backend process');
|
||||||
|
this.process.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
this.process.on('exit', () => {
|
||||||
|
clearTimeout(killTimeout);
|
||||||
|
this.process = null;
|
||||||
|
console.log('Backend process stopped');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 优雅关闭
|
||||||
|
this.process.kill('SIGTERM');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建后端管理器实例
|
||||||
|
const backendManager = new BackendManager();
|
||||||
|
|
||||||
|
// 创建主窗口
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
title: 'Termix',
|
||||||
|
icon: path.join(__dirname, '..', 'public', 'icon.png'),
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
webSecurity: !isDev
|
||||||
|
},
|
||||||
|
show: false, // 先不显示,等加载完成
|
||||||
|
});
|
||||||
|
|
||||||
|
// 移除默认菜单栏(Windows/Linux)
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
mainWindow.setMenuBarVisibility(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载应用
|
||||||
|
if (isDev) {
|
||||||
|
// 开发环境:连接到 Vite 开发服务器
|
||||||
|
mainWindow.loadURL('http://localhost:5173');
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
// 生产环境:加载构建后的文件
|
||||||
|
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口准备好后显示
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
mainWindow.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理窗口关闭事件
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!isQuitting && process.platform === 'darwin') {
|
||||||
|
// macOS:隐藏窗口而不是退出
|
||||||
|
event.preventDefault();
|
||||||
|
mainWindow.hide();
|
||||||
|
} else if (!isQuitting && store.get('minimizeToTray', true)) {
|
||||||
|
// Windows/Linux:最小化到托盘
|
||||||
|
event.preventDefault();
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理外部链接
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建系统托盘
|
||||||
|
function createTray() {
|
||||||
|
if (process.platform === 'darwin') return; // macOS 不需要托盘
|
||||||
|
|
||||||
|
const iconPath = path.join(__dirname, '..', 'public', 'icon.png');
|
||||||
|
tray = new Tray(iconPath);
|
||||||
|
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '显示',
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '退出',
|
||||||
|
click: () => {
|
||||||
|
isQuitting = true;
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
tray.setToolTip('Termix');
|
||||||
|
tray.setContextMenu(contextMenu);
|
||||||
|
|
||||||
|
// 双击托盘图标显示窗口
|
||||||
|
tray.on('double-click', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC 通信处理
|
||||||
|
ipcMain.handle('get-backend-port', () => {
|
||||||
|
return backendPort;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-app-version', () => {
|
||||||
|
return app.getVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-platform', () => {
|
||||||
|
return process.platform;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('restart-backend', async () => {
|
||||||
|
try {
|
||||||
|
await backendManager.stop();
|
||||||
|
await backendManager.start();
|
||||||
|
return { success: true, port: backendManager.port };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('show-save-dialog', async (event, options) => {
|
||||||
|
const result = await dialog.showSaveDialog(mainWindow, options);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('show-open-dialog', async (event, options) => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, options);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动更新
|
||||||
|
if (!isDev && autoUpdater) {
|
||||||
|
try {
|
||||||
|
autoUpdater.checkForUpdatesAndNotify();
|
||||||
|
|
||||||
|
autoUpdater.on('update-available', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('update-available');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-downloaded', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('update-downloaded');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Auto-updater not available:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用事件处理
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
try {
|
||||||
|
// 启动后端
|
||||||
|
await backendManager.start();
|
||||||
|
|
||||||
|
// 创建窗口
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
// 创建托盘
|
||||||
|
createTray();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize application:', error);
|
||||||
|
dialog.showErrorBox('启动失败', `无法启动应用: ${error.message}`);
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
} else if (mainWindow) {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', async () => {
|
||||||
|
isQuitting = true;
|
||||||
|
await backendManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理未捕获的异常
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('Uncaught Exception:', error);
|
||||||
|
dialog.showErrorBox('未捕获的异常', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||||
|
});
|
||||||
18
electron/preload-simple.cjs
Normal file
18
electron/preload-simple.cjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
// 暴露简化的 API 给渲染进程
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
// 获取应用版本
|
||||||
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
|
||||||
|
// 获取平台信息
|
||||||
|
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||||
|
|
||||||
|
// 环境检测
|
||||||
|
isElectron: true,
|
||||||
|
isDev: process.env.NODE_ENV === 'development',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加一个标识,让渲染进程知道这是 Electron 环境
|
||||||
|
// 在上下文隔离环境中,使用 contextBridge 暴露
|
||||||
|
contextBridge.exposeInMainWorld('IS_ELECTRON', true);
|
||||||
54
electron/preload.cjs
Normal file
54
electron/preload.cjs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
// 暴露安全的 API 给渲染进程
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
// 获取后端端口
|
||||||
|
getBackendPort: () => ipcRenderer.invoke('get-backend-port'),
|
||||||
|
|
||||||
|
// 获取应用版本
|
||||||
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
|
||||||
|
// 获取平台信息
|
||||||
|
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||||
|
|
||||||
|
// 重启后端
|
||||||
|
restartBackend: () => ipcRenderer.invoke('restart-backend'),
|
||||||
|
|
||||||
|
// 文件对话框
|
||||||
|
showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options),
|
||||||
|
showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options),
|
||||||
|
|
||||||
|
// 监听后端事件
|
||||||
|
onBackendStarted: (callback) => {
|
||||||
|
ipcRenderer.on('backend-started', (event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
onBackendLog: (callback) => {
|
||||||
|
ipcRenderer.on('backend-log', (event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
onBackendError: (callback) => {
|
||||||
|
ipcRenderer.on('backend-error', (event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
// 监听更新事件
|
||||||
|
onUpdateAvailable: (callback) => {
|
||||||
|
ipcRenderer.on('update-available', () => callback());
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdateDownloaded: (callback) => {
|
||||||
|
ipcRenderer.on('update-downloaded', () => callback());
|
||||||
|
},
|
||||||
|
|
||||||
|
// 移除事件监听器
|
||||||
|
removeAllListeners: (channel) => {
|
||||||
|
ipcRenderer.removeAllListeners(channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 环境检测
|
||||||
|
isElectron: true,
|
||||||
|
isDev: process.env.NODE_ENV === 'development',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加一个标识,让渲染进程知道这是 Electron 环境
|
||||||
|
window.IS_ELECTRON = true;
|
||||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -69,6 +69,7 @@
|
|||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
|
"react-simple-keyboard": "^3.8.120",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
@@ -7610,6 +7612,22 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -7870,6 +7888,16 @@
|
|||||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-simple-keyboard": {
|
||||||
|
"version": "3.8.120",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.120.tgz",
|
||||||
|
"integrity": "sha512-VREEGZWXUeqRKvRVg0n8hmoAqz/TSWZEs5UwbfLuan4yKvOQZUFHtS11QGnvIVYjkThh+JYslO2CHT4Lxf5d0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
|
"react-simple-keyboard": "^3.8.120",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
|
|||||||
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 B |
@@ -1,4 +1,130 @@
|
|||||||
{
|
{
|
||||||
|
"credentials": {
|
||||||
|
"credentialsManager": "Credentials Manager",
|
||||||
|
"manageYourSSHCredentials": "Manage your SSH credentials securely",
|
||||||
|
"addCredential": "Add Credential",
|
||||||
|
"createCredential": "Create Credential",
|
||||||
|
"editCredential": "Edit Credential",
|
||||||
|
"viewCredential": "View Credential",
|
||||||
|
"duplicateCredential": "Duplicate Credential",
|
||||||
|
"deleteCredential": "Delete Credential",
|
||||||
|
"updateCredential": "Update Credential",
|
||||||
|
"credentialName": "Credential Name",
|
||||||
|
"credentialDescription": "Description",
|
||||||
|
"username": "Username",
|
||||||
|
"searchCredentials": "Search credentials...",
|
||||||
|
"selectFolder": "Select Folder",
|
||||||
|
"selectAuthType": "Select Auth Type",
|
||||||
|
"allFolders": "All Folders",
|
||||||
|
"allAuthTypes": "All Auth Types",
|
||||||
|
"uncategorized": "Uncategorized",
|
||||||
|
"totalCredentials": "Total",
|
||||||
|
"keyBased": "Key-based",
|
||||||
|
"passwordBased": "Password-based",
|
||||||
|
"folders": "Folders",
|
||||||
|
"noCredentialsMatchFilters": "No credentials match your filters",
|
||||||
|
"noCredentialsYet": "No credentials created yet",
|
||||||
|
"createFirstCredential": "Create your first credential",
|
||||||
|
"failedToFetchCredentials": "Failed to fetch credentials",
|
||||||
|
"credentialDeletedSuccessfully": "Credential deleted successfully",
|
||||||
|
"failedToDeleteCredential": "Failed to delete credential",
|
||||||
|
"confirmDeleteCredential": "Are you sure you want to delete credential \"{{name}}\"?",
|
||||||
|
"credentialCreatedSuccessfully": "Credential created successfully",
|
||||||
|
"credentialUpdatedSuccessfully": "Credential updated successfully",
|
||||||
|
"failedToSaveCredential": "Failed to save credential",
|
||||||
|
"failedToFetchCredentialDetails": "Failed to fetch credential details",
|
||||||
|
"failedToFetchHostsUsing": "Failed to fetch hosts using this credential",
|
||||||
|
"loadingCredentials": "Loading credentials...",
|
||||||
|
"retry": "Retry",
|
||||||
|
"noCredentials": "No Credentials",
|
||||||
|
"noCredentialsMessage": "Start by creating your first SSH credential",
|
||||||
|
"sshCredentials": "SSH Credentials",
|
||||||
|
"credentialsCount": "{{count}} credentials",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"passwordRequired": "Password is required",
|
||||||
|
"sshKeyRequired": "SSH key is required",
|
||||||
|
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
|
||||||
|
"general": "General",
|
||||||
|
"description": "Description",
|
||||||
|
"folder": "Folder",
|
||||||
|
"tags": "Tags",
|
||||||
|
"addTagsSpaceToAdd": "Add tags (press space to add)",
|
||||||
|
"password": "Password",
|
||||||
|
"key": "Key",
|
||||||
|
"sshPrivateKey": "SSH Private Key",
|
||||||
|
"upload": "Upload",
|
||||||
|
"updateKey": "Update Key",
|
||||||
|
"keyPassword": "Key Password (optional)",
|
||||||
|
"keyType": "Key Type",
|
||||||
|
"keyTypeRSA": "RSA",
|
||||||
|
"keyTypeECDSA": "ECDSA",
|
||||||
|
"keyTypeEd25519": "Ed25519",
|
||||||
|
"updateCredential": "Update Credential",
|
||||||
|
"basicInfo": "Basic Info",
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"organization": "Organization",
|
||||||
|
"basicInformation": "Basic Information",
|
||||||
|
"basicInformationDescription": "Enter the basic information for this credential",
|
||||||
|
"authenticationMethod": "Authentication Method",
|
||||||
|
"authenticationMethodDescription": "Choose how you want to authenticate with SSH servers",
|
||||||
|
"organizationDescription": "Organize your credentials with folders and tags",
|
||||||
|
"enterCredentialName": "Enter credential name",
|
||||||
|
"enterCredentialDescription": "Enter description (optional)",
|
||||||
|
"enterUsername": "Enter username",
|
||||||
|
"nameIsRequired": "Credential name is required",
|
||||||
|
"usernameIsRequired": "Username is required",
|
||||||
|
"authenticationType": "Authentication Type",
|
||||||
|
"passwordAuthDescription": "Use password authentication",
|
||||||
|
"sshKeyAuthDescription": "Use SSH key authentication",
|
||||||
|
"passwordIsRequired": "Password is required",
|
||||||
|
"sshKeyIsRequired": "SSH key is required",
|
||||||
|
"sshKeyType": "SSH Key Type",
|
||||||
|
"privateKey": "Private Key",
|
||||||
|
"enterPassword": "Enter password",
|
||||||
|
"enterPrivateKey": "Enter private key",
|
||||||
|
"keyPassphrase": "Key Passphrase",
|
||||||
|
"enterKeyPassphrase": "Enter key passphrase (optional)",
|
||||||
|
"keyPassphraseOptional": "Optional: leave empty if your key has no passphrase",
|
||||||
|
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
|
||||||
|
"uploadKeyFile": "Upload Key File",
|
||||||
|
"generateKeyPair": "Generate Key Pair",
|
||||||
|
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
|
||||||
|
"connectionTestingNotImplemented": "Connection testing feature coming soon",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"selectOrCreateFolder": "Select or create folder",
|
||||||
|
"noFolder": "No folder",
|
||||||
|
"orCreateNewFolder": "Or create new folder",
|
||||||
|
"addTag": "Add tag",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"overview": "Overview",
|
||||||
|
"security": "Security",
|
||||||
|
"usage": "Usage",
|
||||||
|
"securityDetails": "Security Details",
|
||||||
|
"securityDetailsDescription": "View encrypted credential information",
|
||||||
|
"credentialSecured": "Credential Secured",
|
||||||
|
"credentialSecuredDescription": "All sensitive data is encrypted with AES-256",
|
||||||
|
"passwordAuthentication": "Password Authentication",
|
||||||
|
"keyAuthentication": "Key Authentication",
|
||||||
|
"keyType": "Key Type",
|
||||||
|
"securityReminder": "Security Reminder",
|
||||||
|
"securityReminderText": "Never share your credentials. All data is encrypted at rest.",
|
||||||
|
"hostsUsingCredential": "Hosts Using This Credential",
|
||||||
|
"noHostsUsingCredential": "No hosts are currently using this credential",
|
||||||
|
"timesUsed": "Times Used",
|
||||||
|
"lastUsed": "Last Used",
|
||||||
|
"connectedHosts": "Connected Hosts",
|
||||||
|
"created": "Created",
|
||||||
|
"lastModified": "Last Modified",
|
||||||
|
"usageStatistics": "Usage Statistics",
|
||||||
|
"copiedToClipboard": "{{field}} copied to clipboard",
|
||||||
|
"failedToCopy": "Failed to copy to clipboard",
|
||||||
|
"sshKey": "SSH Key",
|
||||||
|
"createCredentialDescription": "Create a new SSH credential for secure access",
|
||||||
|
"editCredentialDescription": "Update the credential information",
|
||||||
|
"listView": "List",
|
||||||
|
"folderView": "Folders",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH Tools",
|
"title": "SSH Tools",
|
||||||
"closeTools": "Close SSH Tools",
|
"closeTools": "Close SSH Tools",
|
||||||
@@ -32,6 +158,7 @@
|
|||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"required": "Required",
|
"required": "Required",
|
||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
|
"clear": "Clear",
|
||||||
"toggleSidebar": "Toggle Sidebar",
|
"toggleSidebar": "Toggle Sidebar",
|
||||||
"sidebar": "Sidebar",
|
"sidebar": "Sidebar",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -72,6 +199,7 @@
|
|||||||
"register": "Register",
|
"register": "Register",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"version" : "Version",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -120,6 +248,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
|
"credentials": "Credentials",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"tunnels": "Tunnels",
|
"tunnels": "Tunnels",
|
||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
@@ -131,10 +260,12 @@
|
|||||||
"closeTab": "Close Tab",
|
"closeTab": "Close Tab",
|
||||||
"sshManager": "SSH Manager",
|
"sshManager": "SSH Manager",
|
||||||
"hostManager": "Host Manager",
|
"hostManager": "Host Manager",
|
||||||
"cannotSplitTab": "Cannot split this tab"
|
"cannotSplitTab": "Cannot split this tab",
|
||||||
|
"tabNavigation": "Tab Navigation"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin Settings",
|
"title": "Admin Settings",
|
||||||
|
"oidc": "OIDC",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"userManagement": "User Management",
|
"userManagement": "User Management",
|
||||||
"makeAdmin": "Make Admin",
|
"makeAdmin": "Make Admin",
|
||||||
@@ -208,7 +339,7 @@
|
|||||||
"downloadSample": "Download Sample",
|
"downloadSample": "Download Sample",
|
||||||
"formatGuide": "Format Guide",
|
"formatGuide": "Format Guide",
|
||||||
"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",
|
||||||
"jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
|
"jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
|
||||||
"noHostsInJson": "No hosts found in JSON file",
|
"noHostsInJson": "No hosts found in JSON file",
|
||||||
@@ -276,6 +407,10 @@
|
|||||||
"authentication": "Authentication",
|
"authentication": "Authentication",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
|
"credential": "Credential",
|
||||||
|
"selectCredential": "Select Credential",
|
||||||
|
"selectCredentialPlaceholder": "Choose a credential...",
|
||||||
|
"credentialRequired": "Credential is required when using credential authentication",
|
||||||
"sshPrivateKey": "SSH Private Key",
|
"sshPrivateKey": "SSH Private Key",
|
||||||
"keyPassword": "Key Password",
|
"keyPassword": "Key Password",
|
||||||
"keyType": "Key Type",
|
"keyType": "Key Type",
|
||||||
@@ -297,7 +432,14 @@
|
|||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"tunnel": "Tunnel",
|
"tunnel": "Tunnel",
|
||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
"hostViewer": "Host Viewer"
|
"hostViewer": "Host Viewer",
|
||||||
|
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The host will be moved to \"No Folder\".",
|
||||||
|
"removedFromFolder": "Host \"{{name}}\" removed from folder successfully",
|
||||||
|
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||||
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
|
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||||
|
"failedToMoveToFolder": "Failed to move host to folder"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
@@ -665,6 +807,9 @@
|
|||||||
"folder": "folder",
|
"folder": "folder",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
"keyPassword": "key password",
|
"keyPassword": "key password",
|
||||||
|
"credentialName": "My SSH Server",
|
||||||
|
"description": "SSH credential description",
|
||||||
|
"searchCredentials": "Search credentials by name, username, or tags...",
|
||||||
"sshConfig": "endpoint ssh configuration",
|
"sshConfig": "endpoint ssh configuration",
|
||||||
"homePath": "/home",
|
"homePath": "/home",
|
||||||
"clientId": "your-client-id",
|
"clientId": "your-client-id",
|
||||||
@@ -675,6 +820,7 @@
|
|||||||
"userIdField": "sub",
|
"userIdField": "sub",
|
||||||
"usernameField": "name",
|
"usernameField": "name",
|
||||||
"scopes": "openid email profile",
|
"scopes": "openid email profile",
|
||||||
|
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
|
||||||
"enterUsername": "Enter username to make admin",
|
"enterUsername": "Enter username to make admin",
|
||||||
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
||||||
"enterPassword": "Enter your password",
|
"enterPassword": "Enter your password",
|
||||||
@@ -810,5 +956,9 @@
|
|||||||
"invalidVerificationCode": "Invalid verification code",
|
"invalidVerificationCode": "Invalid verification code",
|
||||||
"failedToDisableTotp": "Failed to disable TOTP",
|
"failedToDisableTotp": "Failed to disable TOTP",
|
||||||
"failedToGenerateBackupCodes": "Failed to generate backup codes"
|
"failedToGenerateBackupCodes": "Failed to generate backup codes"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"selectHostToStart": "Select a host to start your terminal session",
|
||||||
|
"limitedSupportMessage": "Mobile support is currently limited. A dedicated mobile app is coming soon to enhance your experience."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,130 @@
|
|||||||
{
|
{
|
||||||
|
"credentials": {
|
||||||
|
"credentialsManager": "凭据管理器",
|
||||||
|
"manageYourSSHCredentials": "安全管理您的SSH凭据",
|
||||||
|
"addCredential": "添加凭据",
|
||||||
|
"createCredential": "创建凭据",
|
||||||
|
"editCredential": "编辑凭据",
|
||||||
|
"viewCredential": "查看凭据",
|
||||||
|
"duplicateCredential": "复制凭据",
|
||||||
|
"deleteCredential": "删除凭据",
|
||||||
|
"updateCredential": "更新凭据",
|
||||||
|
"credentialName": "凭据名称",
|
||||||
|
"credentialDescription": "描述",
|
||||||
|
"username": "用户名",
|
||||||
|
"searchCredentials": "搜索凭据...",
|
||||||
|
"selectFolder": "选择文件夹",
|
||||||
|
"selectAuthType": "选择认证类型",
|
||||||
|
"allFolders": "所有文件夹",
|
||||||
|
"allAuthTypes": "所有认证类型",
|
||||||
|
"uncategorized": "未分类",
|
||||||
|
"totalCredentials": "总计",
|
||||||
|
"keyBased": "密钥认证",
|
||||||
|
"passwordBased": "密码认证",
|
||||||
|
"folders": "文件夹",
|
||||||
|
"noCredentialsMatchFilters": "没有符合筛选条件的凭据",
|
||||||
|
"noCredentialsYet": "还未创建凭据",
|
||||||
|
"createFirstCredential": "创建您的第一个凭据",
|
||||||
|
"failedToFetchCredentials": "获取凭据失败",
|
||||||
|
"credentialDeletedSuccessfully": "凭据删除成功",
|
||||||
|
"failedToDeleteCredential": "删除凭据失败",
|
||||||
|
"confirmDeleteCredential": "确定要删除凭据「{{name}}」吗?",
|
||||||
|
"credentialCreatedSuccessfully": "凭据创建成功",
|
||||||
|
"credentialUpdatedSuccessfully": "凭据更新成功",
|
||||||
|
"failedToSaveCredential": "保存凭据失败",
|
||||||
|
"failedToFetchCredentialDetails": "获取凭据详情失败",
|
||||||
|
"failedToFetchHostsUsing": "获取使用此凭据的主机失败",
|
||||||
|
"loadingCredentials": "正在加载凭据...",
|
||||||
|
"retry": "重试",
|
||||||
|
"noCredentials": "暂无凭据",
|
||||||
|
"noCredentialsMessage": "开始创建您的第一个SSH凭据",
|
||||||
|
"sshCredentials": "SSH凭据",
|
||||||
|
"credentialsCount": "{{count}} 个凭据",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"passwordRequired": "密码为必填项",
|
||||||
|
"sshKeyRequired": "SSH密钥为必填项",
|
||||||
|
"credentialAddedSuccessfully": "凭据「{{name}}」添加成功",
|
||||||
|
"general": "常规",
|
||||||
|
"description": "描述",
|
||||||
|
"folder": "文件夹",
|
||||||
|
"tags": "标签",
|
||||||
|
"addTagsSpaceToAdd": "添加标签(按空格键添加)",
|
||||||
|
"password": "密码",
|
||||||
|
"key": "密钥",
|
||||||
|
"sshPrivateKey": "SSH私钥",
|
||||||
|
"upload": "上传",
|
||||||
|
"updateKey": "更新密钥",
|
||||||
|
"keyPassword": "密钥密码(可选)",
|
||||||
|
"keyType": "密钥类型",
|
||||||
|
"keyTypeRSA": "RSA",
|
||||||
|
"keyTypeECDSA": "ECDSA",
|
||||||
|
"keyTypeEd25519": "Ed25519",
|
||||||
|
"updateCredential": "更新凭据",
|
||||||
|
"basicInfo": "基本信息",
|
||||||
|
"authentication": "认证方式",
|
||||||
|
"organization": "组织管理",
|
||||||
|
"basicInformation": "基本信息",
|
||||||
|
"basicInformationDescription": "输入此凭据的基本信息",
|
||||||
|
"authenticationMethod": "认证方式",
|
||||||
|
"authenticationMethodDescription": "选择如何与SSH服务器进行认证",
|
||||||
|
"organizationDescription": "使用文件夹和标签来组织您的凭据",
|
||||||
|
"enterCredentialName": "输入凭据名称",
|
||||||
|
"enterCredentialDescription": "输入描述(可选)",
|
||||||
|
"enterUsername": "输入用户名",
|
||||||
|
"nameIsRequired": "凭据名称是必需的",
|
||||||
|
"usernameIsRequired": "用户名是必需的",
|
||||||
|
"authenticationType": "认证类型",
|
||||||
|
"passwordAuthDescription": "使用密码认证",
|
||||||
|
"sshKeyAuthDescription": "使用SSH密钥认证",
|
||||||
|
"passwordIsRequired": "密码是必需的",
|
||||||
|
"sshKeyIsRequired": "SSH密钥是必需的",
|
||||||
|
"sshKeyType": "SSH密钥类型",
|
||||||
|
"privateKey": "私钥",
|
||||||
|
"enterPassword": "输入密码",
|
||||||
|
"enterPrivateKey": "输入私钥",
|
||||||
|
"keyPassphrase": "密钥密码",
|
||||||
|
"enterKeyPassphrase": "输入密钥密码(可选)",
|
||||||
|
"keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空",
|
||||||
|
"leaveEmptyToKeepCurrent": "留空以保持当前值",
|
||||||
|
"uploadKeyFile": "上传密钥文件",
|
||||||
|
"generateKeyPair": "生成密钥对",
|
||||||
|
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
|
||||||
|
"connectionTestingNotImplemented": "连接测试功能即将推出",
|
||||||
|
"testConnection": "测试连接",
|
||||||
|
"selectOrCreateFolder": "选择或创建文件夹",
|
||||||
|
"noFolder": "无文件夹",
|
||||||
|
"orCreateNewFolder": "或创建新文件夹",
|
||||||
|
"addTag": "添加标签",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"overview": "概览",
|
||||||
|
"security": "安全",
|
||||||
|
"usage": "使用情况",
|
||||||
|
"securityDetails": "安全详情",
|
||||||
|
"securityDetailsDescription": "查看加密的凭据信息",
|
||||||
|
"credentialSecured": "凭据已加密",
|
||||||
|
"credentialSecuredDescription": "所有敏感数据均使用AES-256加密",
|
||||||
|
"passwordAuthentication": "密码认证",
|
||||||
|
"keyAuthentication": "密钥认证",
|
||||||
|
"keyType": "密钥类型",
|
||||||
|
"securityReminder": "安全提醒",
|
||||||
|
"securityReminderText": "请勿分享您的凭据。所有数据均已静态加密。",
|
||||||
|
"hostsUsingCredential": "使用此凭据的主机",
|
||||||
|
"noHostsUsingCredential": "当前没有主机使用此凭据",
|
||||||
|
"timesUsed": "使用次数",
|
||||||
|
"lastUsed": "最后使用",
|
||||||
|
"connectedHosts": "连接的主机",
|
||||||
|
"created": "创建时间",
|
||||||
|
"lastModified": "最后修改",
|
||||||
|
"usageStatistics": "使用统计",
|
||||||
|
"copiedToClipboard": "{{field}}已复制到剪贴板",
|
||||||
|
"failedToCopy": "复制到剪贴板失败",
|
||||||
|
"sshKey": "SSH密钥",
|
||||||
|
"createCredentialDescription": "创建新的SSH凭据以进行安全访问",
|
||||||
|
"editCredentialDescription": "更新凭据信息",
|
||||||
|
"listView": "列表",
|
||||||
|
"folderView": "文件夹",
|
||||||
|
"unknown": "未知"
|
||||||
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH 工具",
|
"title": "SSH 工具",
|
||||||
"closeTools": "关闭 SSH 工具",
|
"closeTools": "关闭 SSH 工具",
|
||||||
@@ -32,6 +158,7 @@
|
|||||||
"loading": "加载中",
|
"loading": "加载中",
|
||||||
"required": "必填",
|
"required": "必填",
|
||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
|
"clear": "清除",
|
||||||
"toggleSidebar": "切换侧边栏",
|
"toggleSidebar": "切换侧边栏",
|
||||||
"sidebar": "侧边栏",
|
"sidebar": "侧边栏",
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
@@ -120,6 +247,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
"hosts": "主机",
|
"hosts": "主机",
|
||||||
|
"credentials": "凭据",
|
||||||
"terminal": "终端",
|
"terminal": "终端",
|
||||||
"tunnels": "隧道",
|
"tunnels": "隧道",
|
||||||
"fileManager": "文件管理器",
|
"fileManager": "文件管理器",
|
||||||
@@ -131,10 +259,12 @@
|
|||||||
"closeTab": "关闭标签页",
|
"closeTab": "关闭标签页",
|
||||||
"sshManager": "SSH 管理器",
|
"sshManager": "SSH 管理器",
|
||||||
"hostManager": "主机管理器",
|
"hostManager": "主机管理器",
|
||||||
"cannotSplitTab": "无法分割此标签页"
|
"cannotSplitTab": "无法分割此标签页",
|
||||||
|
"tabNavigation": "标签导航"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "管理员设置",
|
"title": "管理员设置",
|
||||||
|
"oidc": "OIDC",
|
||||||
"users": "用户",
|
"users": "用户",
|
||||||
"userManagement": "用户管理",
|
"userManagement": "用户管理",
|
||||||
"makeAdmin": "设为管理员",
|
"makeAdmin": "设为管理员",
|
||||||
@@ -223,6 +353,7 @@
|
|||||||
"port": "端口",
|
"port": "端口",
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
|
"hostName": "主机名",
|
||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
"passwordRequired": "使用密码认证时需要密码",
|
"passwordRequired": "使用密码认证时需要密码",
|
||||||
@@ -296,6 +427,10 @@
|
|||||||
"authentication": "认证方式",
|
"authentication": "认证方式",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"key": "密钥",
|
"key": "密钥",
|
||||||
|
"credential": "凭证",
|
||||||
|
"selectCredential": "选择凭证",
|
||||||
|
"selectCredentialPlaceholder": "选择一个凭证...",
|
||||||
|
"credentialRequired": "使用凭证认证时需要选择凭证",
|
||||||
"sshPrivateKey": "SSH 私钥",
|
"sshPrivateKey": "SSH 私钥",
|
||||||
"keyPassword": "密钥密码",
|
"keyPassword": "密钥密码",
|
||||||
"keyType": "密钥类型",
|
"keyType": "密钥类型",
|
||||||
@@ -334,7 +469,14 @@
|
|||||||
"general": "常规",
|
"general": "常规",
|
||||||
"terminal": "终端",
|
"terminal": "终端",
|
||||||
"tunnel": "隧道",
|
"tunnel": "隧道",
|
||||||
"fileManager": "文件管理器"
|
"fileManager": "文件管理器",
|
||||||
|
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
|
||||||
|
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
|
||||||
|
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||||
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
|
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
|
"failedToMoveToFolder": "移动主机到文件夹失败"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "终端",
|
"title": "终端",
|
||||||
@@ -613,7 +755,7 @@
|
|||||||
"firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建",
|
"firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建",
|
||||||
"external": "外部",
|
"external": "外部",
|
||||||
"loginWithExternal": "使用外部提供商登录",
|
"loginWithExternal": "使用外部提供商登录",
|
||||||
"loginWithExternalDesc": "使用您配置的外部身份提供商登录",
|
"loginWithExternalDesc": "使用您配置的外部身份提供者登录",
|
||||||
"resetPasswordButton": "重置密码",
|
"resetPasswordButton": "重置密码",
|
||||||
"sendResetCode": "发送重置代码",
|
"sendResetCode": "发送重置代码",
|
||||||
"resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。",
|
"resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。",
|
||||||
@@ -701,6 +843,9 @@
|
|||||||
"hostname": "主机名",
|
"hostname": "主机名",
|
||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
|
"credentialName": "我的SSH服务器",
|
||||||
|
"description": "SSH凭据描述",
|
||||||
|
"searchCredentials": "按名称、用户名或标签搜索凭据...",
|
||||||
"keyPassword": "密钥密码",
|
"keyPassword": "密钥密码",
|
||||||
"sshConfig": "端点 SSH 配置",
|
"sshConfig": "端点 SSH 配置",
|
||||||
"homePath": "/home",
|
"homePath": "/home",
|
||||||
@@ -712,6 +857,7 @@
|
|||||||
"userIdField": "sub",
|
"userIdField": "sub",
|
||||||
"usernameField": "name",
|
"usernameField": "name",
|
||||||
"scopes": "openid email profile",
|
"scopes": "openid email profile",
|
||||||
|
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
|
||||||
"enterUsername": "输入用户名以设为管理员",
|
"enterUsername": "输入用户名以设为管理员",
|
||||||
"searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...",
|
"searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...",
|
||||||
"enterPassword": "输入您的密码",
|
"enterPassword": "输入您的密码",
|
||||||
@@ -851,5 +997,9 @@
|
|||||||
"invalidVerificationCode": "无效的验证码",
|
"invalidVerificationCode": "无效的验证码",
|
||||||
"failedToDisableTotp": "禁用 TOTP 失败",
|
"failedToDisableTotp": "禁用 TOTP 失败",
|
||||||
"failedToGenerateBackupCodes": "生成备用码失败"
|
"failedToGenerateBackupCodes": "生成备用码失败"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"selectHostToStart": "选择一个主机以开始您的终端会话",
|
||||||
|
"limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,12 @@ import bodyParser from 'body-parser';
|
|||||||
import userRoutes from './routes/users.js';
|
import userRoutes from './routes/users.js';
|
||||||
import sshRoutes from './routes/ssh.js';
|
import sshRoutes from './routes/ssh.js';
|
||||||
import alertRoutes from './routes/alerts.js';
|
import alertRoutes from './routes/alerts.js';
|
||||||
|
import credentialsRoutes from './routes/credentials.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -143,10 +146,21 @@ app.get('/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/version', async (req, res) => {
|
app.get('/version', async (req, res) => {
|
||||||
const localVersion = process.env.VERSION;
|
let localVersion = process.env.VERSION;
|
||||||
|
|
||||||
if (!localVersion) {
|
if (!localVersion) {
|
||||||
return res.status(401).send('Local Version Not Set');
|
try {
|
||||||
|
const packagePath = path.resolve(process.cwd(), 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
|
localVersion = packageJson.version;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to read version from package.json:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localVersion) {
|
||||||
|
logger.error('No version information available');
|
||||||
|
return res.status(404).send('Local Version Not Set');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -166,6 +180,7 @@ app.get('/version', async (req, res) => {
|
|||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
|
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
|
||||||
|
localVersion: localVersion,
|
||||||
version: remoteVersion,
|
version: remoteVersion,
|
||||||
latest_release: {
|
latest_release: {
|
||||||
tag_name: releaseData.data.tag_name,
|
tag_name: releaseData.data.tag_name,
|
||||||
@@ -235,9 +250,11 @@ app.get('/releases/rss', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
app.use('/users', userRoutes);
|
app.use('/users', userRoutes);
|
||||||
app.use('/ssh', sshRoutes);
|
app.use('/ssh', sshRoutes);
|
||||||
app.use('/alerts', alertRoutes);
|
app.use('/alerts', alertRoutes);
|
||||||
|
app.use('/credentials', credentialsRoutes);
|
||||||
|
|
||||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
logger.error('Unhandled error:', err);
|
logger.error('Unhandled error:', err);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as schema from './schema.js';
|
|||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { MigrationManager } from '../migrations/migrator.js';
|
||||||
|
|
||||||
const dbIconSymbol = '🗄️';
|
const dbIconSymbol = '🗄️';
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
@@ -432,6 +433,9 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
||||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||||
|
|
||||||
|
// Add credential_id column for SSH credentials management
|
||||||
|
addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)');
|
||||||
|
|
||||||
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
||||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||||
@@ -440,15 +444,27 @@ const migrateSchema = () => {
|
|||||||
logger.success('Schema migration completed');
|
logger.success('Schema migration completed');
|
||||||
};
|
};
|
||||||
|
|
||||||
migrateSchema();
|
const initializeDatabase = async () => {
|
||||||
|
migrateSchema();
|
||||||
|
|
||||||
try {
|
// Run new migration system
|
||||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
const migrationManager = new MigrationManager(sqlite);
|
||||||
if (!row) {
|
await migrationManager.runMigrations();
|
||||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
|
||||||
|
try {
|
||||||
|
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||||
|
if (!row) {
|
||||||
|
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Could not initialize default settings');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
};
|
||||||
logger.warn('Could not initialize default settings');
|
|
||||||
}
|
// Initialize database (async)
|
||||||
|
initializeDatabase().catch(error => {
|
||||||
|
logger.error('Failed to initialize database:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
export const db = drizzle(sqlite, {schema});
|
export const db = drizzle(sqlite, {schema});
|
||||||
@@ -17,7 +17,7 @@ export const users = sqliteTable('users', {
|
|||||||
identifier_path: text('identifier_path'),
|
identifier_path: text('identifier_path'),
|
||||||
name_path: text('name_path'),
|
name_path: text('name_path'),
|
||||||
scopes: text().default("openid email profile"),
|
scopes: text().default("openid email profile"),
|
||||||
|
|
||||||
totp_secret: text('totp_secret'),
|
totp_secret: text('totp_secret'),
|
||||||
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false),
|
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false),
|
||||||
totp_backup_codes: text('totp_backup_codes'),
|
totp_backup_codes: text('totp_backup_codes'),
|
||||||
@@ -39,10 +39,13 @@ export const sshData = sqliteTable('ssh_data', {
|
|||||||
tags: text('tags'),
|
tags: text('tags'),
|
||||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||||
authType: text('auth_type').notNull(),
|
authType: text('auth_type').notNull(),
|
||||||
|
// Legacy credential fields - kept for backward compatibility
|
||||||
password: text('password'),
|
password: text('password'),
|
||||||
key: text('key', {length: 8192}),
|
key: text('key', {length: 8192}),
|
||||||
keyPassword: text('key_password'),
|
keyPassword: text('key_password'),
|
||||||
keyType: text('key_type'),
|
keyType: text('key_type'),
|
||||||
|
// New credential management
|
||||||
|
credentialId: integer('credential_id').references(() => sshCredentials.id),
|
||||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||||
tunnelConnections: text('tunnel_connections'),
|
tunnelConnections: text('tunnel_connections'),
|
||||||
@@ -84,4 +87,32 @@ export const dismissedAlerts = sqliteTable('dismissed_alerts', {
|
|||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
alertId: text('alert_id').notNull(),
|
alertId: text('alert_id').notNull(),
|
||||||
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSH Credentials Management Tables
|
||||||
|
export const sshCredentials = sqliteTable('ssh_credentials', {
|
||||||
|
id: integer('id').primaryKey({autoIncrement: true}),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
folder: text('folder'),
|
||||||
|
tags: text('tags'),
|
||||||
|
authType: text('auth_type').notNull(), // 'password' | 'key'
|
||||||
|
username: text('username').notNull(),
|
||||||
|
encryptedPassword: text('encrypted_password'), // AES encrypted
|
||||||
|
encryptedKey: text('encrypted_key', {length: 16384}), // AES encrypted SSH key
|
||||||
|
encryptedKeyPassword: text('encrypted_key_password'), // AES encrypted key passphrase
|
||||||
|
keyType: text('key_type'), // 'rsa' | 'ecdsa' | 'ed25519'
|
||||||
|
usageCount: integer('usage_count').notNull().default(0),
|
||||||
|
lastUsed: text('last_used'),
|
||||||
|
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sshCredentialUsage = sqliteTable('ssh_credential_usage', {
|
||||||
|
id: integer('id').primaryKey({autoIncrement: true}),
|
||||||
|
credentialId: integer('credential_id').notNull().references(() => sshCredentials.id),
|
||||||
|
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
usedAt: text('used_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Database } from 'better-sqlite3';
|
||||||
|
|
||||||
|
export const up = (db: Database) => {
|
||||||
|
// Create SSH credentials table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
folder TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
auth_type TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
encrypted_password TEXT,
|
||||||
|
encrypted_key TEXT,
|
||||||
|
encrypted_key_password TEXT,
|
||||||
|
key_type TEXT,
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_used TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create credential usage tracking table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
credential_id INTEGER NOT NULL REFERENCES ssh_credentials(id),
|
||||||
|
host_id INTEGER NOT NULL REFERENCES ssh_data(id),
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add credential_id column to ssh_data table if it doesn't exist
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(ssh_data)`).all();
|
||||||
|
const hasCredentialId = columns.some((col: any) => col.name === 'credential_id');
|
||||||
|
|
||||||
|
if (!hasCredentialId) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE ssh_data
|
||||||
|
ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes for better performance
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_user_id ON ssh_credentials(user_id)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_folder ON ssh_credentials(folder)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_credential_id ON ssh_credential_usage(credential_id)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_host_id ON ssh_credential_usage(host_id)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_data_credential_id ON ssh_data(credential_id)`);
|
||||||
|
|
||||||
|
console.log('✅ Added SSH credentials management tables');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (db: Database) => {
|
||||||
|
// Remove credential_id column from ssh_data table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE ssh_data_backup AS SELECT
|
||||||
|
id, user_id, name, ip, port, username, folder, tags, pin, auth_type,
|
||||||
|
password, key, key_password, key_type, enable_terminal, enable_tunnel,
|
||||||
|
tunnel_connections, enable_file_manager, default_path, created_at, updated_at
|
||||||
|
FROM ssh_data
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`DROP TABLE ssh_data`);
|
||||||
|
db.exec(`ALTER TABLE ssh_data_backup RENAME TO ssh_data`);
|
||||||
|
|
||||||
|
// Drop credential tables
|
||||||
|
db.exec(`DROP TABLE IF EXISTS ssh_credential_usage`);
|
||||||
|
db.exec(`DROP TABLE IF EXISTS ssh_credentials`);
|
||||||
|
|
||||||
|
console.log('✅ Removed SSH credentials management tables');
|
||||||
|
};
|
||||||
261
src/backend/database/migrations/migrator.ts
Normal file
261
src/backend/database/migrations/migrator.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import type { Database } from 'better-sqlite3';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { readFileSync, readdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.log(`${timestamp} ${chalk.cyan('[MIGRATION]')} ${msg}`);
|
||||||
|
},
|
||||||
|
warn: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.warn(`${timestamp} ${chalk.yellow('[MIGRATION]')} ${msg}`);
|
||||||
|
},
|
||||||
|
error: (msg: string, err?: unknown): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.error(`${timestamp} ${chalk.redBright('[MIGRATION]')} ${msg}`);
|
||||||
|
if (err) console.error(err);
|
||||||
|
},
|
||||||
|
success: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.log(`${timestamp} ${chalk.greenBright('[MIGRATION]')} ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Migration {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
up: (db: Database) => void;
|
||||||
|
down: (db: Database) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MigrationManager {
|
||||||
|
private db: Database;
|
||||||
|
private migrationsPath: string;
|
||||||
|
|
||||||
|
constructor(db: Database) {
|
||||||
|
this.db = db;
|
||||||
|
this.migrationsPath = __dirname;
|
||||||
|
this.ensureMigrationsTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureMigrationsTable() {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAppliedMigrations(): Set<string> {
|
||||||
|
const applied = this.db.prepare('SELECT id FROM migrations').all() as { id: string }[];
|
||||||
|
return new Set(applied.map(m => m.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadMigration(filename: string): Promise<Migration | null> {
|
||||||
|
try {
|
||||||
|
const migrationPath = join(this.migrationsPath, filename);
|
||||||
|
// Convert to file:// URL for Windows compatibility
|
||||||
|
const migrationUrl = process.platform === 'win32'
|
||||||
|
? `file:///${migrationPath.replace(/\\/g, '/')}`
|
||||||
|
: migrationPath;
|
||||||
|
const migration = await import(migrationUrl);
|
||||||
|
|
||||||
|
// Extract migration ID and name from filename
|
||||||
|
const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/);
|
||||||
|
if (!matches) {
|
||||||
|
logger.warn(`Skipping invalid migration filename: ${filename}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, id, name] = matches;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id.padStart(3, '0'),
|
||||||
|
name: name.replace(/-/g, ' '),
|
||||||
|
up: migration.up,
|
||||||
|
down: migration.down
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to load migration ${filename}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMigrationFiles(): string[] {
|
||||||
|
try {
|
||||||
|
return readdirSync(this.migrationsPath)
|
||||||
|
.filter(file => (file.endsWith('.ts') || file.endsWith('.js')) && !file.includes('migrator'))
|
||||||
|
.sort();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to read migrations directory:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runMigrations(): Promise<void> {
|
||||||
|
logger.info('Starting database migrations...');
|
||||||
|
|
||||||
|
const migrationFiles = this.getMigrationFiles();
|
||||||
|
if (migrationFiles.length === 0) {
|
||||||
|
logger.info('No migrations found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedMigrations = this.getAppliedMigrations();
|
||||||
|
const migrations: Migration[] = [];
|
||||||
|
|
||||||
|
// Load all migrations
|
||||||
|
for (const filename of migrationFiles) {
|
||||||
|
const migration = await this.loadMigration(filename);
|
||||||
|
if (migration) {
|
||||||
|
migrations.push(migration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out already applied migrations
|
||||||
|
const pendingMigrations = migrations.filter(m => !appliedMigrations.has(m.id));
|
||||||
|
|
||||||
|
if (pendingMigrations.length === 0) {
|
||||||
|
logger.info('All migrations are already applied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${pendingMigrations.length} pending migration(s)`);
|
||||||
|
|
||||||
|
// Run pending migrations in transaction
|
||||||
|
const transaction = this.db.transaction(() => {
|
||||||
|
for (const migration of pendingMigrations) {
|
||||||
|
logger.info(`Applying migration ${migration.id}: ${migration.name}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
migration.up(this.db);
|
||||||
|
|
||||||
|
// Record the migration
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO migrations (id, name)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`).run(migration.id, migration.name);
|
||||||
|
|
||||||
|
logger.success(`Applied migration ${migration.id}: ${migration.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to apply migration ${migration.id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
transaction();
|
||||||
|
logger.success(`Successfully applied ${pendingMigrations.length} migration(s)`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Migration transaction failed, rolling back:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollbackMigration(targetId?: string): Promise<void> {
|
||||||
|
logger.warn('Starting migration rollback...');
|
||||||
|
|
||||||
|
const appliedMigrations = this.db.prepare(`
|
||||||
|
SELECT id, name FROM migrations
|
||||||
|
ORDER BY id DESC
|
||||||
|
`).all() as { id: string; name: string }[];
|
||||||
|
|
||||||
|
if (appliedMigrations.length === 0) {
|
||||||
|
logger.info('No migrations to rollback');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationsToRollback = targetId
|
||||||
|
? appliedMigrations.filter(m => m.id >= targetId)
|
||||||
|
: [appliedMigrations[0]]; // Only rollback the latest
|
||||||
|
|
||||||
|
const migrationFiles = this.getMigrationFiles();
|
||||||
|
const migrations: Migration[] = [];
|
||||||
|
|
||||||
|
// Load migrations that need to be rolled back
|
||||||
|
for (const filename of migrationFiles) {
|
||||||
|
const migration = await this.loadMigration(filename);
|
||||||
|
if (migration && migrationsToRollback.some(m => m.id === migration.id)) {
|
||||||
|
migrations.push(migration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort in reverse order for rollback
|
||||||
|
migrations.sort((a, b) => b.id.localeCompare(a.id));
|
||||||
|
|
||||||
|
const transaction = this.db.transaction(() => {
|
||||||
|
for (const migration of migrations) {
|
||||||
|
logger.info(`Rolling back migration ${migration.id}: ${migration.name}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
migration.down(this.db);
|
||||||
|
|
||||||
|
// Remove the migration record
|
||||||
|
this.db.prepare(`DELETE FROM migrations WHERE id = ?`).run(migration.id);
|
||||||
|
|
||||||
|
logger.success(`Rolled back migration ${migration.id}: ${migration.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to rollback migration ${migration.id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
transaction();
|
||||||
|
logger.success(`Successfully rolled back ${migrations.length} migration(s)`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Rollback transaction failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMigrationStatus(): { id: string; name: string; applied: boolean }[] {
|
||||||
|
const migrationFiles = this.getMigrationFiles();
|
||||||
|
const appliedMigrations = this.getAppliedMigrations();
|
||||||
|
|
||||||
|
return migrationFiles.map(filename => {
|
||||||
|
const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/);
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
const [, id, name] = matches;
|
||||||
|
const migrationId = id.padStart(3, '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: migrationId,
|
||||||
|
name: name.replace(/-/g, ' '),
|
||||||
|
applied: appliedMigrations.has(migrationId)
|
||||||
|
};
|
||||||
|
}).filter(Boolean) as { id: string; name: string; applied: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
printStatus(): void {
|
||||||
|
const status = this.getMigrationStatus();
|
||||||
|
|
||||||
|
logger.info('Migration Status:');
|
||||||
|
console.log(chalk.gray('─'.repeat(60)));
|
||||||
|
|
||||||
|
status.forEach(migration => {
|
||||||
|
const statusIcon = migration.applied ? chalk.green('✓') : chalk.yellow('○');
|
||||||
|
const statusText = migration.applied ? chalk.green('Applied') : chalk.yellow('Pending');
|
||||||
|
console.log(`${statusIcon} ${migration.id} - ${migration.name} [${statusText}]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(chalk.gray('─'.repeat(60)));
|
||||||
|
const appliedCount = status.filter(m => m.applied).length;
|
||||||
|
console.log(`Total: ${status.length} migrations, ${appliedCount} applied, ${status.length - appliedCount} pending`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MigrationManager };
|
||||||
|
export type { Migration };
|
||||||
270
src/backend/database/routes/credentials.ts
Normal file
270
src/backend/database/routes/credentials.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import {credentialService} from '../../services/credentials.js';
|
||||||
|
import type {Request, Response, NextFunction} from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
const credIconSymbol = '🔐';
|
||||||
|
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||||
|
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#0f766e')(`[${credIconSymbol}]`)} ${message}`;
|
||||||
|
};
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string): void => {
|
||||||
|
console.log(formatMessage('info', chalk.cyan, msg));
|
||||||
|
},
|
||||||
|
warn: (msg: string): void => {
|
||||||
|
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||||
|
},
|
||||||
|
error: (msg: string, err?: unknown): void => {
|
||||||
|
console.error(formatMessage('error', chalk.redBright, msg));
|
||||||
|
if (err) console.error(err);
|
||||||
|
},
|
||||||
|
success: (msg: string): void => {
|
||||||
|
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
interface JWTPayload {
|
||||||
|
userId: string;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(val: any): val is string {
|
||||||
|
return typeof val === 'string' && val.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
logger.warn('Missing or invalid Authorization header');
|
||||||
|
return res.status(401).json({error: 'Missing or invalid Authorization header'});
|
||||||
|
}
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||||
|
(req as any).userId = payload.userId;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Invalid or expired token');
|
||||||
|
return res.status(401).json({error: 'Invalid or expired token'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new credential
|
||||||
|
// POST /credentials
|
||||||
|
router.post('/', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
folder,
|
||||||
|
tags,
|
||||||
|
authType,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
key,
|
||||||
|
keyPassword,
|
||||||
|
keyType
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) {
|
||||||
|
logger.warn('Invalid credential creation data');
|
||||||
|
return res.status(400).json({error: 'Name and username are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['password', 'key'].includes(authType)) {
|
||||||
|
logger.warn('Invalid auth type');
|
||||||
|
return res.status(400).json({error: 'Auth type must be "password" or "key"'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await credentialService.createCredential(userId, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
folder,
|
||||||
|
tags,
|
||||||
|
authType,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
key,
|
||||||
|
keyPassword,
|
||||||
|
keyType
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.success(`Created credential: ${name}`);
|
||||||
|
res.status(201).json(credential);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to create credential', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to create credential'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all credentials for the authenticated user
|
||||||
|
// GET /credentials
|
||||||
|
router.get('/', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
logger.warn('Invalid userId for credential fetch');
|
||||||
|
return res.status(400).json({error: 'Invalid userId'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = await credentialService.getUserCredentials(userId);
|
||||||
|
res.json(credentials);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch credentials', err);
|
||||||
|
res.status(500).json({error: 'Failed to fetch credentials'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all unique credential folders for the authenticated user
|
||||||
|
// GET /credentials/folders
|
||||||
|
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
logger.warn('Invalid userId for credential folder fetch');
|
||||||
|
return res.status(400).json({error: 'Invalid userId'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folders = await credentialService.getCredentialsFolders(userId);
|
||||||
|
res.json(folders);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch credential folders', err);
|
||||||
|
res.status(500).json({error: 'Failed to fetch credential folders'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a specific credential by ID (with decrypted secrets)
|
||||||
|
// GET /credentials/:id
|
||||||
|
router.get('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
logger.warn('Invalid request for credential fetch');
|
||||||
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await credentialService.getCredentialWithSecrets(userId, parseInt(id));
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
return res.status(404).json({error: 'Credential not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(credential);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch credential', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to fetch credential'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a credential
|
||||||
|
// PUT /credentials/:id
|
||||||
|
router.put('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {id} = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
logger.warn('Invalid request for credential update');
|
||||||
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await credentialService.updateCredential(userId, parseInt(id), updateData);
|
||||||
|
logger.success(`Updated credential ID ${id}`);
|
||||||
|
res.json(credential);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to update credential', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to update credential'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a credential
|
||||||
|
// DELETE /credentials/:id
|
||||||
|
router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {id} = req.params;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
logger.warn('Invalid request for credential deletion');
|
||||||
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await credentialService.deleteCredential(userId, parseInt(id));
|
||||||
|
logger.success(`Deleted credential ID ${id}`);
|
||||||
|
res.json({message: 'Credential deleted successfully'});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to delete credential', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to delete credential'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply a credential to an SSH host (for quick application)
|
||||||
|
// POST /credentials/:id/apply-to-host/:hostId
|
||||||
|
router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {id: credentialId, hostId} = req.params;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
||||||
|
logger.warn('Invalid request for credential application');
|
||||||
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||||
|
await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId));
|
||||||
|
|
||||||
|
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
|
||||||
|
res.json({message: 'Credential applied to host successfully'});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to apply credential to host', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to apply credential to host'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get hosts using a specific credential
|
||||||
|
// GET /credentials/:id/hosts
|
||||||
|
router.get('/:id/hosts', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {id: credentialId} = req.params;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !credentialId) {
|
||||||
|
logger.warn('Invalid request for credential hosts fetch');
|
||||||
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||||
|
const hosts = await sshHostService.getHostsUsingCredential(userId, parseInt(credentialId));
|
||||||
|
|
||||||
|
res.json(hosts);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch hosts using credential', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -144,6 +144,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
authMethod,
|
authMethod,
|
||||||
|
authType,
|
||||||
|
credentialId,
|
||||||
key,
|
key,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
keyType,
|
keyType,
|
||||||
@@ -160,6 +162,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
return res.status(400).json({error: 'Invalid SSH data'});
|
return res.status(400).json({error: 'Invalid SSH data'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveAuthType = authType || authMethod;
|
||||||
const sshDataObj: any = {
|
const sshDataObj: any = {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
name,
|
name,
|
||||||
@@ -168,7 +171,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
ip,
|
ip,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
authType: authMethod,
|
authType: effectiveAuthType,
|
||||||
|
credentialId: credentialId || null,
|
||||||
pin: !!pin ? 1 : 0,
|
pin: !!pin ? 1 : 0,
|
||||||
enableTerminal: !!enableTerminal ? 1 : 0,
|
enableTerminal: !!enableTerminal ? 1 : 0,
|
||||||
enableTunnel: !!enableTunnel ? 1 : 0,
|
enableTunnel: !!enableTunnel ? 1 : 0,
|
||||||
@@ -177,12 +181,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
defaultPath: defaultPath || null,
|
defaultPath: defaultPath || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authMethod === 'password') {
|
if (effectiveAuthType === 'password') {
|
||||||
sshDataObj.password = password;
|
sshDataObj.password = password;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.keyPassword = null;
|
||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
} else if (authMethod === 'key') {
|
} else if (effectiveAuthType === 'key') {
|
||||||
sshDataObj.key = key;
|
sshDataObj.key = key;
|
||||||
sshDataObj.keyPassword = keyPassword;
|
sshDataObj.keyPassword = keyPassword;
|
||||||
sshDataObj.keyType = keyType;
|
sshDataObj.keyType = keyType;
|
||||||
@@ -232,6 +236,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
authMethod,
|
authMethod,
|
||||||
|
authType,
|
||||||
|
credentialId,
|
||||||
key,
|
key,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
keyType,
|
keyType,
|
||||||
@@ -249,6 +255,7 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
|||||||
return res.status(400).json({error: 'Invalid SSH data'});
|
return res.status(400).json({error: 'Invalid SSH data'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveAuthType = authType || authMethod;
|
||||||
const sshDataObj: any = {
|
const sshDataObj: any = {
|
||||||
name,
|
name,
|
||||||
folder,
|
folder,
|
||||||
@@ -256,7 +263,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
|||||||
ip,
|
ip,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
authType: authMethod,
|
authType: effectiveAuthType,
|
||||||
|
credentialId: credentialId || null,
|
||||||
pin: !!pin ? 1 : 0,
|
pin: !!pin ? 1 : 0,
|
||||||
enableTerminal: !!enableTerminal ? 1 : 0,
|
enableTerminal: !!enableTerminal ? 1 : 0,
|
||||||
enableTunnel: !!enableTunnel ? 1 : 0,
|
enableTunnel: !!enableTunnel ? 1 : 0,
|
||||||
@@ -265,15 +273,23 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
|||||||
defaultPath: defaultPath || null,
|
defaultPath: defaultPath || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authMethod === 'password') {
|
if (effectiveAuthType === 'password') {
|
||||||
sshDataObj.password = password;
|
if (password) {
|
||||||
|
sshDataObj.password = password;
|
||||||
|
}
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.keyPassword = null;
|
||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
} else if (authMethod === 'key') {
|
} else if (effectiveAuthType === 'key') {
|
||||||
sshDataObj.key = key;
|
if (key) {
|
||||||
sshDataObj.keyPassword = keyPassword;
|
sshDataObj.key = key;
|
||||||
sshDataObj.keyType = keyType;
|
}
|
||||||
|
if (keyPassword !== undefined) {
|
||||||
|
sshDataObj.keyPassword = keyPassword;
|
||||||
|
}
|
||||||
|
if (keyType) {
|
||||||
|
sshDataObj.keyType = keyType;
|
||||||
|
}
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +402,112 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Route: Get all folders with usage statistics for the authenticated user (requires JWT)
|
||||||
|
// GET /ssh/folders/with-stats
|
||||||
|
router.get('/db/folders/with-stats', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
logger.warn('Invalid userId for SSH folder stats fetch');
|
||||||
|
return res.status(400).json({error: 'Invalid userId'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await db
|
||||||
|
.select({
|
||||||
|
folder: sshData.folder,
|
||||||
|
hostId: sshData.id,
|
||||||
|
hostName: sshData.name,
|
||||||
|
hostIp: sshData.ip
|
||||||
|
})
|
||||||
|
.from(sshData)
|
||||||
|
.where(eq(sshData.userId, userId));
|
||||||
|
|
||||||
|
const folderStats: Record<string, {
|
||||||
|
name: string;
|
||||||
|
hostCount: number;
|
||||||
|
hosts: Array<{id: number; name?: string; ip: string}>;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
data.forEach(d => {
|
||||||
|
if (d.folder && d.folder.trim() !== '') {
|
||||||
|
if (!folderStats[d.folder]) {
|
||||||
|
folderStats[d.folder] = {
|
||||||
|
name: d.folder,
|
||||||
|
hostCount: 0,
|
||||||
|
hosts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
folderStats[d.folder].hostCount++;
|
||||||
|
folderStats[d.folder].hosts.push({
|
||||||
|
id: d.hostId,
|
||||||
|
name: d.hostName || undefined,
|
||||||
|
ip: d.hostIp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = Object.values(folderStats).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch SSH folder statistics', err);
|
||||||
|
res.status(500).json({error: 'Failed to fetch SSH folder statistics'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Rename folder across all hosts for the authenticated user (requires JWT)
|
||||||
|
// PUT /ssh/folders/rename
|
||||||
|
router.put('/db/folders/rename', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {oldName, newName} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
|
||||||
|
logger.warn('Invalid parameters for folder rename');
|
||||||
|
return res.status(400).json({error: 'userId, oldName, and newName are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldName === newName) {
|
||||||
|
logger.warn('Attempt to rename folder to the same name');
|
||||||
|
return res.status(400).json({error: 'New folder name must be different from old name'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if the old folder exists
|
||||||
|
const existingHosts = await db
|
||||||
|
.select({id: sshData.id})
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.userId, userId),
|
||||||
|
eq(sshData.folder, oldName)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (existingHosts.length === 0) {
|
||||||
|
logger.warn(`Attempt to rename non-existent folder: ${oldName}`);
|
||||||
|
return res.status(404).json({error: 'Folder not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all hosts using this folder name
|
||||||
|
const result = await db
|
||||||
|
.update(sshData)
|
||||||
|
.set({folder: newName})
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.userId, userId),
|
||||||
|
eq(sshData.folder, oldName)
|
||||||
|
));
|
||||||
|
|
||||||
|
logger.success(`Renamed folder "${oldName}" to "${newName}" for ${existingHosts.length} hosts`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Folder renamed successfully`,
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
affectedHostsCount: existingHosts.length
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to rename SSH folder', err);
|
||||||
|
res.status(500).json({error: 'Failed to rename SSH folder'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Route: Delete SSH host by id (requires JWT)
|
// Route: Delete SSH host by id (requires JWT)
|
||||||
// DELETE /ssh/host/:id
|
// DELETE /ssh/host/:id
|
||||||
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
@@ -691,6 +813,109 @@ router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, r
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Route: Get SSH host by ID with resolved credentials (requires JWT)
|
||||||
|
// GET /ssh/host/:id/with-credentials
|
||||||
|
router.get('/db/host/:id/with-credentials', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const {id} = req.params;
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
logger.warn('Invalid request for SSH host with credentials fetch');
|
||||||
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||||
|
const host = await sshHostService.getHostWithCredentials(userId, parseInt(id));
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return res.status(404).json({error: 'SSH host not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(host);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch SSH host with credentials', err);
|
||||||
|
res.status(500).json({error: 'Failed to fetch SSH host with credentials'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Apply credential to SSH host (requires JWT)
|
||||||
|
// POST /ssh/host/:id/apply-credential
|
||||||
|
router.post('/db/host/:id/apply-credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const {id: hostId} = req.params;
|
||||||
|
const {credentialId} = req.body;
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !hostId || !credentialId) {
|
||||||
|
logger.warn('Invalid request for applying credential to host');
|
||||||
|
return res.status(400).json({error: 'Host ID and credential ID are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||||
|
await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId));
|
||||||
|
|
||||||
|
res.json({message: 'Credential applied to host successfully'});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to apply credential to host', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to apply credential to host'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Remove credential from SSH host (requires JWT)
|
||||||
|
// DELETE /ssh/host/:id/credential
|
||||||
|
router.delete('/db/host/:id/credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const {id: hostId} = req.params;
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !hostId) {
|
||||||
|
logger.warn('Invalid request for removing credential from host');
|
||||||
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||||
|
await sshHostService.removeCredentialFromHost(userId, parseInt(hostId));
|
||||||
|
|
||||||
|
res.json({message: 'Credential removed from host successfully'});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to remove credential from host', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to remove credential from host'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Migrate host to managed credential (requires JWT)
|
||||||
|
// POST /ssh/host/:id/migrate-to-credential
|
||||||
|
router.post('/db/host/:id/migrate-to-credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const {id: hostId} = req.params;
|
||||||
|
const {credentialName} = req.body;
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !hostId || !credentialName) {
|
||||||
|
logger.warn('Invalid request for migrating host to credential');
|
||||||
|
return res.status(400).json({error: 'Host ID and credential name are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||||
|
const credentialId = await sshHostService.migrateHostToCredential(userId, parseInt(hostId), credentialName);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Host migrated to managed credential successfully',
|
||||||
|
credentialId
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to migrate host to credential', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to migrate host to credential'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Route: Bulk import SSH hosts from JSON (requires JWT)
|
// Route: Bulk import SSH hosts from JSON (requires JWT)
|
||||||
// POST /ssh/bulk-import
|
// POST /ssh/bulk-import
|
||||||
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
|
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ router.get('/oidc-config', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
|
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return res.status(404).json({error: 'OIDC not configured'});
|
return res.json(null);
|
||||||
}
|
}
|
||||||
res.json(JSON.parse((row as any).value));
|
res.json(JSON.parse((row as any).value));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1314,11 +1314,10 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => {
|
|||||||
await db.delete(fileManagerRecent).where(eq(fileManagerRecent.userId, targetUserId));
|
await db.delete(fileManagerRecent).where(eq(fileManagerRecent.userId, targetUserId));
|
||||||
await db.delete(fileManagerPinned).where(eq(fileManagerPinned.userId, targetUserId));
|
await db.delete(fileManagerPinned).where(eq(fileManagerPinned.userId, targetUserId));
|
||||||
await db.delete(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, targetUserId));
|
await db.delete(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, targetUserId));
|
||||||
|
|
||||||
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId));
|
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId));
|
||||||
|
|
||||||
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
|
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
|
||||||
|
|
||||||
// Note: All user-related data has been deleted above
|
// Note: All user-related data has been deleted above
|
||||||
// The tables config_editor_* and shared_hosts don't exist in the current schema
|
// The tables config_editor_* and shared_hosts don't exist in the current schema
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
|
|||||||
370
src/backend/services/credentials.ts
Normal file
370
src/backend/services/credentials.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import {db} from '../database/db/index.js';
|
||||||
|
import {sshCredentials, sshCredentialUsage, sshData} from '../database/db/schema.js';
|
||||||
|
import {eq, and, desc, sql} from 'drizzle-orm';
|
||||||
|
import {encryptionService} from './encryption.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
|
||||||
|
},
|
||||||
|
warn: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
|
||||||
|
},
|
||||||
|
error: (msg: string, err?: unknown): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
|
||||||
|
if (err) console.error(err);
|
||||||
|
},
|
||||||
|
success: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CredentialInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
folder?: string;
|
||||||
|
tags?: string[];
|
||||||
|
authType: 'password' | 'key';
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialOutput {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
folder?: string;
|
||||||
|
tags: string[];
|
||||||
|
authType: 'password' | 'key';
|
||||||
|
username: string;
|
||||||
|
keyType?: string;
|
||||||
|
usageCount: number;
|
||||||
|
lastUsed?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialWithSecrets extends CredentialOutput {
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CredentialService {
|
||||||
|
/**
|
||||||
|
* Create a new credential
|
||||||
|
*/
|
||||||
|
async createCredential(userId: string, input: CredentialInput): Promise<CredentialOutput> {
|
||||||
|
try {
|
||||||
|
// Validate input
|
||||||
|
if (!input.name?.trim()) {
|
||||||
|
throw new Error('Credential name is required');
|
||||||
|
}
|
||||||
|
if (!input.username?.trim()) {
|
||||||
|
throw new Error('Username is required');
|
||||||
|
}
|
||||||
|
if (!['password', 'key'].includes(input.authType)) {
|
||||||
|
throw new Error('Invalid auth type');
|
||||||
|
}
|
||||||
|
if (input.authType === 'password' && !input.password) {
|
||||||
|
throw new Error('Password is required for password authentication');
|
||||||
|
}
|
||||||
|
if (input.authType === 'key' && !input.key) {
|
||||||
|
throw new Error('SSH key is required for key authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt sensitive data
|
||||||
|
let encryptedPassword: string | null = null;
|
||||||
|
let encryptedKey: string | null = null;
|
||||||
|
let encryptedKeyPassword: string | null = null;
|
||||||
|
|
||||||
|
if (input.authType === 'password' && input.password) {
|
||||||
|
encryptedPassword = encryptionService.encryptToString(input.password);
|
||||||
|
} else if (input.authType === 'key') {
|
||||||
|
if (input.key) {
|
||||||
|
encryptedKey = encryptionService.encryptToString(input.key);
|
||||||
|
}
|
||||||
|
if (input.keyPassword) {
|
||||||
|
encryptedKeyPassword = encryptionService.encryptToString(input.keyPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialData = {
|
||||||
|
userId,
|
||||||
|
name: input.name.trim(),
|
||||||
|
description: input.description?.trim() || null,
|
||||||
|
folder: input.folder?.trim() || null,
|
||||||
|
tags: Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || ''),
|
||||||
|
authType: input.authType,
|
||||||
|
username: input.username.trim(),
|
||||||
|
encryptedPassword,
|
||||||
|
encryptedKey,
|
||||||
|
encryptedKeyPassword,
|
||||||
|
keyType: input.keyType || null,
|
||||||
|
usageCount: 0,
|
||||||
|
lastUsed: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await db.insert(sshCredentials).values(credentialData).returning();
|
||||||
|
const created = result[0];
|
||||||
|
|
||||||
|
logger.success(`Created credential "${input.name}" (ID: ${created.id})`);
|
||||||
|
|
||||||
|
return this.formatCredentialOutput(created);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create credential', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all credentials for a user
|
||||||
|
*/
|
||||||
|
async getUserCredentials(userId: string): Promise<CredentialOutput[]> {
|
||||||
|
try {
|
||||||
|
const credentials = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, userId))
|
||||||
|
.orderBy(desc(sshCredentials.updatedAt));
|
||||||
|
|
||||||
|
return credentials.map(cred => this.formatCredentialOutput(cred));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch user credentials', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a credential by ID with decrypted secrets
|
||||||
|
*/
|
||||||
|
async getCredentialWithSecrets(userId: string, credentialId: number): Promise<CredentialWithSecrets | null> {
|
||||||
|
try {
|
||||||
|
const credentials = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(and(
|
||||||
|
eq(sshCredentials.id, credentialId),
|
||||||
|
eq(sshCredentials.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (credentials.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = credentials[0];
|
||||||
|
const output: CredentialWithSecrets = {
|
||||||
|
...this.formatCredentialOutput(credential)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decrypt sensitive data
|
||||||
|
try {
|
||||||
|
if (credential.encryptedPassword) {
|
||||||
|
output.password = encryptionService.decryptFromString(credential.encryptedPassword);
|
||||||
|
}
|
||||||
|
if (credential.encryptedKey) {
|
||||||
|
output.key = encryptionService.decryptFromString(credential.encryptedKey);
|
||||||
|
}
|
||||||
|
if (credential.encryptedKeyPassword) {
|
||||||
|
output.keyPassword = encryptionService.decryptFromString(credential.encryptedKeyPassword);
|
||||||
|
}
|
||||||
|
} catch (decryptError) {
|
||||||
|
logger.error(`Failed to decrypt credential ${credentialId}`, decryptError);
|
||||||
|
throw new Error('Failed to decrypt credential data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get credential with secrets', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a credential
|
||||||
|
*/
|
||||||
|
async updateCredential(userId: string, credentialId: number, input: Partial<CredentialInput>): Promise<CredentialOutput> {
|
||||||
|
try {
|
||||||
|
// Check if credential exists and belongs to user
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(and(
|
||||||
|
eq(sshCredentials.id, credentialId),
|
||||||
|
eq(sshCredentials.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new Error('Credential not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.name !== undefined) updateData.name = input.name.trim();
|
||||||
|
if (input.description !== undefined) updateData.description = input.description?.trim() || null;
|
||||||
|
if (input.folder !== undefined) updateData.folder = input.folder?.trim() || null;
|
||||||
|
if (input.tags !== undefined) {
|
||||||
|
updateData.tags = Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || '');
|
||||||
|
}
|
||||||
|
if (input.username !== undefined) updateData.username = input.username.trim();
|
||||||
|
if (input.authType !== undefined) updateData.authType = input.authType;
|
||||||
|
if (input.keyType !== undefined) updateData.keyType = input.keyType;
|
||||||
|
|
||||||
|
// Handle sensitive data updates
|
||||||
|
if (input.password !== undefined) {
|
||||||
|
updateData.encryptedPassword = input.password ? encryptionService.encryptToString(input.password) : null;
|
||||||
|
}
|
||||||
|
if (input.key !== undefined) {
|
||||||
|
updateData.encryptedKey = input.key ? encryptionService.encryptToString(input.key) : null;
|
||||||
|
}
|
||||||
|
if (input.keyPassword !== undefined) {
|
||||||
|
updateData.encryptedKeyPassword = input.keyPassword ? encryptionService.encryptToString(input.keyPassword) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(sshCredentials)
|
||||||
|
.set(updateData)
|
||||||
|
.where(and(
|
||||||
|
eq(sshCredentials.id, credentialId),
|
||||||
|
eq(sshCredentials.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fetch updated credential
|
||||||
|
const updated = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, credentialId));
|
||||||
|
|
||||||
|
logger.success(`Updated credential ID ${credentialId}`);
|
||||||
|
|
||||||
|
return this.formatCredentialOutput(updated[0]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update credential', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a credential
|
||||||
|
*/
|
||||||
|
async deleteCredential(userId: string, credentialId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if credential is in use
|
||||||
|
const hostsUsingCredential = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.credentialId, credentialId),
|
||||||
|
eq(sshData.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (hostsUsingCredential.length > 0) {
|
||||||
|
throw new Error(`Cannot delete credential: it is currently used by ${hostsUsingCredential.length} host(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete usage records
|
||||||
|
await db
|
||||||
|
.delete(sshCredentialUsage)
|
||||||
|
.where(and(
|
||||||
|
eq(sshCredentialUsage.credentialId, credentialId),
|
||||||
|
eq(sshCredentialUsage.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Delete credential
|
||||||
|
const result = await db
|
||||||
|
.delete(sshCredentials)
|
||||||
|
.where(and(
|
||||||
|
eq(sshCredentials.id, credentialId),
|
||||||
|
eq(sshCredentials.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
logger.success(`Deleted credential ID ${credentialId}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete credential', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record credential usage
|
||||||
|
*/
|
||||||
|
async recordUsage(userId: string, credentialId: number, hostId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Record usage
|
||||||
|
await db.insert(sshCredentialUsage).values({
|
||||||
|
credentialId,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update credential usage stats
|
||||||
|
await db
|
||||||
|
.update(sshCredentials)
|
||||||
|
.set({
|
||||||
|
usageCount: sql`${sshCredentials.usageCount} + 1`,
|
||||||
|
lastUsed: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.where(eq(sshCredentials.id, credentialId));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to record credential usage', error);
|
||||||
|
// Don't throw - this is not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get credentials grouped by folder
|
||||||
|
*/
|
||||||
|
async getCredentialsFolders(userId: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.select({folder: sshCredentials.folder})
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, userId));
|
||||||
|
|
||||||
|
const folderCounts: Record<string, number> = {};
|
||||||
|
result.forEach(r => {
|
||||||
|
if (r.folder && r.folder.trim() !== '') {
|
||||||
|
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get credential folders', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatCredentialOutput(credential: any): CredentialOutput {
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
name: credential.name,
|
||||||
|
description: credential.description,
|
||||||
|
folder: credential.folder,
|
||||||
|
tags: typeof credential.tags === 'string'
|
||||||
|
? (credential.tags ? credential.tags.split(',').filter(Boolean) : [])
|
||||||
|
: [],
|
||||||
|
authType: credential.authType,
|
||||||
|
username: credential.username,
|
||||||
|
keyType: credential.keyType,
|
||||||
|
usageCount: credential.usageCount || 0,
|
||||||
|
lastUsed: credential.lastUsed,
|
||||||
|
createdAt: credential.createdAt,
|
||||||
|
updatedAt: credential.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const credentialService = new CredentialService();
|
||||||
133
src/backend/services/encryption.ts
Normal file
133
src/backend/services/encryption.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const KEY_LENGTH = 32; // 256 bits
|
||||||
|
const IV_LENGTH = 16; // 128 bits
|
||||||
|
const TAG_LENGTH = 16; // 128 bits
|
||||||
|
|
||||||
|
interface EncryptionResult {
|
||||||
|
encrypted: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecryptionInput {
|
||||||
|
encrypted: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EncryptionService {
|
||||||
|
private key: Buffer;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Get or generate encryption key
|
||||||
|
const keyEnv = process.env.CREDENTIAL_ENCRYPTION_KEY;
|
||||||
|
if (keyEnv) {
|
||||||
|
this.key = Buffer.from(keyEnv, 'hex');
|
||||||
|
if (this.key.length !== KEY_LENGTH) {
|
||||||
|
throw new Error(`Invalid encryption key length. Expected ${KEY_LENGTH} bytes, got ${this.key.length}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Generate a new key - in production, this should be stored securely
|
||||||
|
this.key = crypto.randomBytes(KEY_LENGTH);
|
||||||
|
console.warn(chalk.yellow(`[SECURITY] Generated new encryption key. Store this in CREDENTIAL_ENCRYPTION_KEY: ${this.key.toString('hex')}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt sensitive data
|
||||||
|
* @param plaintext - The data to encrypt
|
||||||
|
* @returns Encryption result with encrypted data, IV, and tag
|
||||||
|
*/
|
||||||
|
encrypt(plaintext: string): EncryptionResult {
|
||||||
|
try {
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
tag: tag.toString('hex')
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt sensitive data
|
||||||
|
* @param input - Encrypted data with IV and tag
|
||||||
|
* @returns Decrypted plaintext
|
||||||
|
*/
|
||||||
|
decrypt(input: DecryptionInput): string {
|
||||||
|
try {
|
||||||
|
const iv = Buffer.from(input.iv, 'hex');
|
||||||
|
const tag = Buffer.from(input.tag, 'hex');
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(input.encrypted, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data and return as single base64-encoded string
|
||||||
|
* Format: iv:tag:encrypted
|
||||||
|
*/
|
||||||
|
encryptToString(plaintext: string): string {
|
||||||
|
const result = this.encrypt(plaintext);
|
||||||
|
const combined = `${result.iv}:${result.tag}:${result.encrypted}`;
|
||||||
|
return Buffer.from(combined).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data from base64-encoded string
|
||||||
|
*/
|
||||||
|
decryptFromString(encryptedString: string): string {
|
||||||
|
try {
|
||||||
|
const combined = Buffer.from(encryptedString, 'base64').toString();
|
||||||
|
const parts = combined.split(':');
|
||||||
|
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error('Invalid encrypted string format');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.decrypt({
|
||||||
|
iv: parts[0],
|
||||||
|
tag: parts[1],
|
||||||
|
encrypted: parts[2]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to decrypt string: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string can be decrypted (useful for testing)
|
||||||
|
*/
|
||||||
|
canDecrypt(encryptedString: string): boolean {
|
||||||
|
try {
|
||||||
|
this.decryptFromString(encryptedString);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const encryptionService = new EncryptionService();
|
||||||
|
|
||||||
|
// Types for external use
|
||||||
|
export type { EncryptionResult, DecryptionInput };
|
||||||
277
src/backend/services/ssh-host.ts
Normal file
277
src/backend/services/ssh-host.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import {db} from '../database/db/index.js';
|
||||||
|
import {sshData, sshCredentials} from '../database/db/schema.js';
|
||||||
|
import {eq, and} from 'drizzle-orm';
|
||||||
|
import {credentialService} from './credentials.js';
|
||||||
|
import {encryptionService} from './encryption.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
|
||||||
|
},
|
||||||
|
warn: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
|
||||||
|
},
|
||||||
|
error: (msg: string, err?: unknown): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
|
||||||
|
if (err) console.error(err);
|
||||||
|
},
|
||||||
|
success: (msg: string): void => {
|
||||||
|
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SSHHostWithCredentials {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder?: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
// Auth data - either from credential or legacy fields
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
credentialId?: number;
|
||||||
|
credentialName?: string;
|
||||||
|
// Other fields
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SSHHostService {
|
||||||
|
/**
|
||||||
|
* Get SSH host with resolved credentials
|
||||||
|
*/
|
||||||
|
async getHostWithCredentials(userId: string, hostId: number): Promise<SSHHostWithCredentials | null> {
|
||||||
|
try {
|
||||||
|
const hosts = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.id, hostId),
|
||||||
|
eq(sshData.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = hosts[0];
|
||||||
|
return await this.resolveHostCredentials(host);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get host ${hostId} with credentials`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a credential to an SSH host
|
||||||
|
*/
|
||||||
|
async applyCredentialToHost(userId: string, hostId: number, credentialId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Verify credential exists and belongs to user
|
||||||
|
const credential = await credentialService.getCredentialWithSecrets(userId, credentialId);
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Credential not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update host to reference the credential and clear legacy fields
|
||||||
|
await db
|
||||||
|
.update(sshData)
|
||||||
|
.set({
|
||||||
|
credentialId: credentialId,
|
||||||
|
username: credential.username,
|
||||||
|
authType: credential.authType,
|
||||||
|
// Clear legacy credential fields since we're using the credential reference
|
||||||
|
password: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.id, hostId),
|
||||||
|
eq(sshData.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Record credential usage
|
||||||
|
await credentialService.recordUsage(userId, credentialId, hostId);
|
||||||
|
|
||||||
|
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to apply credential ${credentialId} to host ${hostId}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove credential from host (revert to legacy mode)
|
||||||
|
*/
|
||||||
|
async removeCredentialFromHost(userId: string, hostId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(sshData)
|
||||||
|
.set({
|
||||||
|
credentialId: null,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.id, hostId),
|
||||||
|
eq(sshData.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
logger.success(`Removed credential reference from host ${hostId}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to remove credential from host ${hostId}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all hosts using a specific credential
|
||||||
|
*/
|
||||||
|
async getHostsUsingCredential(userId: string, credentialId: number): Promise<SSHHostWithCredentials[]> {
|
||||||
|
try {
|
||||||
|
const hosts = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.credentialId, credentialId),
|
||||||
|
eq(sshData.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
const result: SSHHostWithCredentials[] = [];
|
||||||
|
for (const host of hosts) {
|
||||||
|
const resolved = await this.resolveHostCredentials(host);
|
||||||
|
result.push(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get hosts using credential ${credentialId}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve host credentials from either credential reference or legacy fields
|
||||||
|
*/
|
||||||
|
private async resolveHostCredentials(host: any): Promise<SSHHostWithCredentials> {
|
||||||
|
const baseHost: SSHHostWithCredentials = {
|
||||||
|
id: host.id,
|
||||||
|
userId: host.userId,
|
||||||
|
name: host.name,
|
||||||
|
ip: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
folder: host.folder,
|
||||||
|
tags: typeof host.tags === 'string'
|
||||||
|
? (host.tags ? host.tags.split(',').filter(Boolean) : [])
|
||||||
|
: [],
|
||||||
|
pin: !!host.pin,
|
||||||
|
authType: host.authType,
|
||||||
|
enableTerminal: !!host.enableTerminal,
|
||||||
|
enableTunnel: !!host.enableTunnel,
|
||||||
|
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
||||||
|
enableFileManager: !!host.enableFileManager,
|
||||||
|
defaultPath: host.defaultPath,
|
||||||
|
createdAt: host.createdAt,
|
||||||
|
updatedAt: host.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If host uses a credential reference, get credentials from there
|
||||||
|
if (host.credentialId) {
|
||||||
|
try {
|
||||||
|
const credential = await credentialService.getCredentialWithSecrets(host.userId, host.credentialId);
|
||||||
|
if (credential) {
|
||||||
|
baseHost.credentialId = credential.id;
|
||||||
|
baseHost.credentialName = credential.name;
|
||||||
|
baseHost.username = credential.username;
|
||||||
|
baseHost.authType = credential.authType;
|
||||||
|
baseHost.password = credential.password;
|
||||||
|
baseHost.key = credential.key;
|
||||||
|
baseHost.keyPassword = credential.keyPassword;
|
||||||
|
baseHost.keyType = credential.keyType;
|
||||||
|
} else {
|
||||||
|
logger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`);
|
||||||
|
// Fall back to legacy data
|
||||||
|
this.addLegacyCredentials(baseHost, host);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to resolve credential ${host.credentialId} for host ${host.id}`, error);
|
||||||
|
// Fall back to legacy data
|
||||||
|
this.addLegacyCredentials(baseHost, host);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use legacy credential fields
|
||||||
|
this.addLegacyCredentials(baseHost, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addLegacyCredentials(baseHost: SSHHostWithCredentials, host: any): void {
|
||||||
|
baseHost.password = host.password;
|
||||||
|
baseHost.key = host.key;
|
||||||
|
baseHost.keyPassword = host.keyPassword;
|
||||||
|
baseHost.keyType = host.keyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a host from legacy credentials to a managed credential
|
||||||
|
*/
|
||||||
|
async migrateHostToCredential(userId: string, hostId: number, credentialName: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const host = await this.getHostWithCredentials(userId, hostId);
|
||||||
|
if (!host) {
|
||||||
|
throw new Error('Host not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.credentialId) {
|
||||||
|
throw new Error('Host already uses managed credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new credential from the host's legacy data
|
||||||
|
const credentialData = {
|
||||||
|
name: credentialName,
|
||||||
|
description: `Migrated from host ${host.name || host.ip}`,
|
||||||
|
folder: host.folder,
|
||||||
|
tags: host.tags,
|
||||||
|
authType: host.authType as 'password' | 'key',
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
key: host.key,
|
||||||
|
keyPassword: host.keyPassword,
|
||||||
|
keyType: host.keyType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = await credentialService.createCredential(userId, credentialData);
|
||||||
|
|
||||||
|
// Apply the new credential to the host
|
||||||
|
await this.applyCredentialToHost(userId, hostId, credential.id);
|
||||||
|
|
||||||
|
logger.success(`Migrated host ${hostId} to managed credential ${credential.id}`);
|
||||||
|
return credential.id;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to migrate host ${hostId} to credential`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sshHostService = new SSHHostService();
|
||||||
@@ -4,18 +4,10 @@ import fetch from 'node-fetch';
|
|||||||
import net from 'net';
|
import net from 'net';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import {Client, type ConnectConfig} from 'ssh2';
|
import {Client, type ConnectConfig} from 'ssh2';
|
||||||
|
import {sshHostService} from '../services/ssh-host.js';
|
||||||
|
import type {SSHHostWithCredentials} from '../services/ssh-host.js';
|
||||||
|
|
||||||
type HostRecord = {
|
// Removed HostRecord - using SSHHostWithCredentials from ssh-host service instead
|
||||||
id: number;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username?: string;
|
|
||||||
authType?: 'password' | 'key' | string;
|
|
||||||
password?: string | null;
|
|
||||||
key?: string | null;
|
|
||||||
keyPassword?: string | null;
|
|
||||||
keyType?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HostStatus = 'online' | 'offline';
|
type HostStatus = 'online' | 'offline';
|
||||||
|
|
||||||
@@ -69,7 +61,7 @@ const logger = {
|
|||||||
|
|
||||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||||
|
|
||||||
async function fetchAllHosts(): Promise<HostRecord[]> {
|
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||||
const url = 'http://localhost:8081/ssh/db/host/internal';
|
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
@@ -79,30 +71,55 @@ async function fetchAllHosts(): Promise<HostRecord[]> {
|
|||||||
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const hosts: HostRecord[] = (Array.isArray(data) ? data : []).map((h: any) => ({
|
const rawHosts = Array.isArray(data) ? data : [];
|
||||||
id: Number(h.id),
|
|
||||||
ip: String(h.ip),
|
// Resolve credentials for each host using the same logic as main SSH connections
|
||||||
port: Number(h.port) || 22,
|
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||||
username: h.username,
|
for (const rawHost of rawHosts) {
|
||||||
authType: h.authType,
|
try {
|
||||||
password: h.password ?? null,
|
// Use the ssh-host service to properly resolve credentials
|
||||||
key: h.key ?? null,
|
const host = await sshHostService.getHostWithCredentials(rawHost.userId, rawHost.id);
|
||||||
keyPassword: h.keyPassword ?? null,
|
if (host) {
|
||||||
keyType: h.keyType ?? null,
|
hostsWithCredentials.push(host);
|
||||||
})).filter(h => !!h.id && !!h.ip && !!h.port);
|
}
|
||||||
return hosts;
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to resolve credentials for host ${rawHost.id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostsWithCredentials.filter(h => !!h.id && !!h.ip && !!h.port);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch hosts from database service', err);
|
logger.error('Failed to fetch hosts from database service', err);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHostById(id: number): Promise<HostRecord | undefined> {
|
async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undefined> {
|
||||||
const all = await fetchAllHosts();
|
try {
|
||||||
return all.find(h => h.id === id);
|
// Get all users that might own this host
|
||||||
|
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {'x-internal-request': '1'}
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const rawHost = (Array.isArray(data) ? data : []).find((h: any) => h.id === id);
|
||||||
|
|
||||||
|
if (!rawHost) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ssh-host service to properly resolve credentials
|
||||||
|
return await sshHostService.getHostWithCredentials(rawHost.userId, id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to fetch host ${id}`, err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSshConfig(host: HostRecord): ConnectConfig {
|
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||||
const base: ConnectConfig = {
|
const base: ConnectConfig = {
|
||||||
host: host.ip,
|
host: host.ip,
|
||||||
port: host.port || 22,
|
port: host.port || 22,
|
||||||
@@ -111,37 +128,41 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
|
|||||||
algorithms: {}
|
algorithms: {}
|
||||||
} as ConnectConfig;
|
} as ConnectConfig;
|
||||||
|
|
||||||
|
// Use the same authentication logic as main SSH connections
|
||||||
if (host.authType === 'password') {
|
if (host.authType === 'password') {
|
||||||
(base as any).password = host.password || '';
|
if (!host.password) {
|
||||||
} else if (host.authType === 'key') {
|
throw new Error(`No password available for host ${host.ip}`);
|
||||||
if (host.key) {
|
|
||||||
try {
|
|
||||||
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
|
||||||
throw new Error('Invalid private key format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
||||||
|
|
||||||
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
|
||||||
|
|
||||||
if (host.keyPassword) {
|
|
||||||
(base as any).passphrase = host.keyPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (keyError) {
|
|
||||||
logger.error(`SSH key format error for host ${host.ip}: ${keyError.message}`);
|
|
||||||
if (host.password) {
|
|
||||||
(base as any).password = host.password;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
(base as any).password = host.password;
|
||||||
|
} else if (host.authType === 'key') {
|
||||||
|
if (!host.key) {
|
||||||
|
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
||||||
|
throw new Error('Invalid private key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
|
if (host.keyPassword) {
|
||||||
|
(base as any).passphrase = host.keyPassword;
|
||||||
|
}
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`);
|
||||||
|
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported authentication type '${host.authType}' for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Promise<T>): Promise<T> {
|
async function withSshConnection<T>(host: SSHHostWithCredentials, fn: (client: Client) => Promise<T>): Promise<T> {
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -225,7 +246,7 @@ function kibToGiB(kib: number): number {
|
|||||||
return kib / (1024 * 1024);
|
return kib / (1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectMetrics(host: HostRecord): Promise<{
|
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||||
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
|
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
|
||||||
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
|
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
|
||||||
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
||||||
|
|||||||
202
src/components/CredentialSelector.tsx
Normal file
202
src/components/CredentialSelector.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { FormControl, FormItem, FormLabel } from "@/components/ui/form";
|
||||||
|
import { getCredentials } from '@/ui/main-axios';
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
username: string;
|
||||||
|
authType: 'password' | 'key';
|
||||||
|
folder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialSelectorProps {
|
||||||
|
value?: number | null;
|
||||||
|
onValueChange: (credentialId: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CredentialSelector({ value, onValueChange }: CredentialSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCredentials = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getCredentials();
|
||||||
|
// Handle both possible response formats: direct array or nested object
|
||||||
|
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []);
|
||||||
|
setCredentials(credentialsArray);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch credentials:', error);
|
||||||
|
setCredentials([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCredentials();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [dropdownOpen]);
|
||||||
|
|
||||||
|
const selectedCredential = credentials.find(c => c.id === value);
|
||||||
|
|
||||||
|
const filteredCredentials = credentials.filter(credential => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const searchLower = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
credential.name.toLowerCase().includes(searchLower) ||
|
||||||
|
credential.username.toLowerCase().includes(searchLower) ||
|
||||||
|
(credential.folder && credential.folder.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCredentialSelect = (credential: Credential) => {
|
||||||
|
onValueChange(credential.id);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onValueChange(null);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('hosts.selectCredential')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
t('common.loading')
|
||||||
|
) : selectedCredential ? (
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{selectedCredential.name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground ml-2">
|
||||||
|
({selectedCredential.username} • {selectedCredential.authType})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('hosts.selectCredentialPlaceholder')
|
||||||
|
)}
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="p-2 border-b border-border">
|
||||||
|
<Input
|
||||||
|
placeholder={t('credentials.searchCredentials')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-60 overflow-y-auto p-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
) : filteredCredentials.length === 0 ? (
|
||||||
|
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||||
|
{searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-1">
|
||||||
|
{value && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
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"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
{t('common.clear')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{filteredCredentials.map((credential) => (
|
||||||
|
<Button
|
||||||
|
key={credential.id}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
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 ${
|
||||||
|
credential.id === value ? 'bg-muted' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleCredentialSelect(credential)}
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{credential.name}</span>
|
||||||
|
{credential.folder && (
|
||||||
|
<span className="text-xs bg-muted px-1 rounded">
|
||||||
|
{credential.folder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{credential.username} • {credential.authType}
|
||||||
|
{credential.description && ` • ${credential.description}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
|
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
|
||||||
|
|
||||||
import { ButtonProps } from '@/components/ui/button';
|
import { type ButtonProps } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface ButtonGroupProps {
|
interface ButtonGroupProps {
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ComponentProps<"button">,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: ButtonProps) {
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,4 +59,4 @@ function Button({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants, type ButtonProps }
|
||||||
|
|||||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-4 w-4 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -58,9 +58,9 @@ function SheetContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none",
|
||||||
side === "right" &&
|
side === "right" &&
|
||||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l",
|
||||||
side === "left" &&
|
side === "left" &&
|
||||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r",
|
||||||
side === "top" &&
|
side === "top" &&
|
||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
side === "bottom" &&
|
side === "bottom" &&
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme()
|
const { theme = "system" } = useTheme()
|
||||||
|
|||||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -25,7 +25,7 @@ i18n
|
|||||||
|
|
||||||
// Backend options
|
// Backend options
|
||||||
backend: {
|
backend: {
|
||||||
loadPath: '/locales/{{lng}}/translation.json',
|
loadPath: './locales/{{lng}}/translation.json',
|
||||||
},
|
},
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
|
|||||||
@@ -123,6 +123,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/main.tsx
64
src/main.tsx
@@ -1,14 +1,70 @@
|
|||||||
import {StrictMode} from 'react'
|
import {StrictMode, useEffect, useState, useRef} from 'react'
|
||||||
import {createRoot} from 'react-dom/client'
|
import {createRoot} from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import DesktopApp from './ui/Desktop/DesktopApp.tsx'
|
||||||
|
import { MobileApp } from './ui/Mobile/MobileApp.tsx'
|
||||||
import {ThemeProvider} from "@/components/theme-provider"
|
import {ThemeProvider} from "@/components/theme-provider"
|
||||||
import './i18n/i18n' // Initialize i18n
|
import './i18n/i18n'
|
||||||
|
|
||||||
|
function useWindowWidth() {
|
||||||
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
|
const lastSwitchTime = useRef(0);
|
||||||
|
const isCurrentlyMobile = useRef(window.innerWidth < 768);
|
||||||
|
const hasSwitchedOnce = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
const newWidth = window.innerWidth;
|
||||||
|
const newIsMobile = newWidth < 768;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (hasSwitchedOnce.current && (now - lastSwitchTime.current) < 10000) {
|
||||||
|
setWidth(newWidth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIsMobile !== isCurrentlyMobile.current && (now - lastSwitchTime.current) > 5000) {
|
||||||
|
lastSwitchTime.current = now;
|
||||||
|
isCurrentlyMobile.current = newIsMobile;
|
||||||
|
hasSwitchedOnce.current = true;
|
||||||
|
setWidth(newWidth);
|
||||||
|
setIsMobile(newIsMobile);
|
||||||
|
} else {
|
||||||
|
setWidth(newWidth);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootApp() {
|
||||||
|
const width = useWindowWidth();
|
||||||
|
const isMobile = width < 768;
|
||||||
|
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||||
|
|
||||||
|
if (isElectron) {
|
||||||
|
return <DesktopApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||||
<App/>
|
<RootApp/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
21
src/types/electron.d.ts
vendored
Normal file
21
src/types/electron.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
interface ElectronAPI {
|
||||||
|
getBackendPort: () => Promise<number>;
|
||||||
|
getAppVersion: () => Promise<string>;
|
||||||
|
getPlatform: () => Promise<string>;
|
||||||
|
restartBackend: () => Promise<{ success: boolean; port?: number; error?: string }>;
|
||||||
|
showSaveDialog: (options: any) => Promise<any>;
|
||||||
|
showOpenDialog: (options: any) => Promise<any>;
|
||||||
|
onBackendStarted: (callback: (data: { port: number }) => void) => void;
|
||||||
|
onBackendLog: (callback: (data: string) => void) => void;
|
||||||
|
onBackendError: (callback: (data: string) => void) => void;
|
||||||
|
onUpdateAvailable: (callback: () => void) => void;
|
||||||
|
onUpdateDownloaded: (callback: () => void) => void;
|
||||||
|
removeAllListeners: (channel: string) => void;
|
||||||
|
isElectron: boolean;
|
||||||
|
isDev: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
electronAPI?: ElectronAPI;
|
||||||
|
IS_ELECTRON?: boolean;
|
||||||
|
}
|
||||||
562
src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
Normal file
562
src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Controller, useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import React, { useEffect, useRef, useState } from "react"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { createCredential, updateCredential, getCredentials } from '@/ui/main-axios'
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
folder?: string;
|
||||||
|
tags: string[];
|
||||||
|
authType: 'password' | 'key';
|
||||||
|
username: string;
|
||||||
|
keyType?: string;
|
||||||
|
usageCount: number;
|
||||||
|
lastUsed?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialEditorProps {
|
||||||
|
editingCredential?: Credential | null;
|
||||||
|
onFormSubmit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const credentialsData = await getCredentials();
|
||||||
|
setCredentials(credentialsData);
|
||||||
|
|
||||||
|
const uniqueFolders = [...new Set(
|
||||||
|
credentialsData
|
||||||
|
.filter(credential => credential.folder && credential.folder.trim() !== '')
|
||||||
|
.map(credential => credential.folder)
|
||||||
|
)].sort();
|
||||||
|
|
||||||
|
setFolders(uniqueFolders);
|
||||||
|
} catch (error) {
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
folder: z.string().optional(),
|
||||||
|
tags: z.array(z.string().min(1)).default([]),
|
||||||
|
authType: z.enum(['password', 'key']),
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().optional(),
|
||||||
|
key: z.instanceof(File).optional().nullable(),
|
||||||
|
keyPassword: z.string().optional(),
|
||||||
|
keyType: z.enum([
|
||||||
|
'rsa',
|
||||||
|
'ecdsa',
|
||||||
|
'ed25519'
|
||||||
|
]).optional(),
|
||||||
|
}).superRefine((data, ctx) => {
|
||||||
|
if (data.authType === 'password') {
|
||||||
|
if (!data.password || data.password.trim() === '') {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t('credentials.passwordRequired'),
|
||||||
|
path: ['password']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.authType === 'key') {
|
||||||
|
if (!data.key && !editingCredential) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t('credentials.sshKeyRequired'),
|
||||||
|
path: ['key']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
name: editingCredential?.name || "",
|
||||||
|
description: editingCredential?.description || "",
|
||||||
|
folder: editingCredential?.folder || "",
|
||||||
|
tags: editingCredential?.tags || [],
|
||||||
|
authType: editingCredential?.authType || "password",
|
||||||
|
username: editingCredential?.username || "",
|
||||||
|
password: "",
|
||||||
|
key: null,
|
||||||
|
keyPassword: "",
|
||||||
|
keyType: "rsa",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingCredential) {
|
||||||
|
const defaultAuthType = editingCredential.key ? 'key' : 'password';
|
||||||
|
|
||||||
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: editingCredential.name || "",
|
||||||
|
description: editingCredential.description || "",
|
||||||
|
folder: editingCredential.folder || "",
|
||||||
|
tags: editingCredential.tags || [],
|
||||||
|
authType: defaultAuthType as 'password' | 'key',
|
||||||
|
username: editingCredential.username || "",
|
||||||
|
password: "",
|
||||||
|
key: null,
|
||||||
|
keyPassword: "",
|
||||||
|
keyType: (editingCredential.keyType as any) || "rsa",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAuthTab('password');
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
folder: "",
|
||||||
|
tags: [],
|
||||||
|
authType: "password",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
key: null,
|
||||||
|
keyPassword: "",
|
||||||
|
keyType: "rsa",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editingCredential, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
try {
|
||||||
|
const formData = data as FormData;
|
||||||
|
|
||||||
|
if (!formData.name || formData.name.trim() === '') {
|
||||||
|
formData.name = formData.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingCredential) {
|
||||||
|
await updateCredential(editingCredential.id, formData);
|
||||||
|
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name }));
|
||||||
|
} else {
|
||||||
|
await createCredential(formData);
|
||||||
|
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onFormSubmit) {
|
||||||
|
onFormSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('credentials.failedToSaveCredential'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
|
||||||
|
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const folderDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const folderValue = form.watch('folder');
|
||||||
|
const filteredFolders = React.useMemo(() => {
|
||||||
|
if (!folderValue) return folders;
|
||||||
|
return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
|
||||||
|
}, [folderValue, folders]);
|
||||||
|
|
||||||
|
const handleFolderClick = (folder: string) => {
|
||||||
|
form.setValue('folder', folder);
|
||||||
|
setFolderDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
folderDropdownRef.current &&
|
||||||
|
!folderDropdownRef.current.contains(event.target as Node) &&
|
||||||
|
folderInputRef.current &&
|
||||||
|
!folderInputRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setFolderDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [folderDropdownOpen]);
|
||||||
|
|
||||||
|
const keyTypeOptions = [
|
||||||
|
{ value: 'rsa', label: t('credentials.keyTypeRSA') },
|
||||||
|
{ value: 'ecdsa', label: t('credentials.keyTypeECDSA') },
|
||||||
|
{ value: 'ed25519', label: t('credentials.keyTypeEd25519') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||||
|
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
keyTypeDropdownOpen &&
|
||||||
|
keyTypeDropdownRef.current &&
|
||||||
|
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
||||||
|
keyTypeButtonRef.current &&
|
||||||
|
!keyTypeButtonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setKeyTypeDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
}, [keyTypeDropdownOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
|
||||||
|
<Form {...form}>
|
||||||
|
<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">
|
||||||
|
<Tabs defaultValue="general" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="general">{t('credentials.general')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="authentication">{t('credentials.authentication')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="general" className="pt-2">
|
||||||
|
<FormLabel className="mb-3 font-bold">{t('credentials.basicInformation')}</FormLabel>
|
||||||
|
<div className="grid grid-cols-12 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-6">
|
||||||
|
<FormLabel>{t('credentials.credentialName')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('placeholders.credentialName')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-6">
|
||||||
|
<FormLabel>{t('credentials.username')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('placeholders.username')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormLabel className="mb-3 mt-3 font-bold">{t('credentials.organization')}</FormLabel>
|
||||||
|
<div className="grid grid-cols-26 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-10">
|
||||||
|
<FormLabel>{t('credentials.description')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('placeholders.description')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="folder"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-10 relative">
|
||||||
|
<FormLabel>{t('credentials.folder')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
ref={folderInputRef}
|
||||||
|
placeholder={t('placeholders.folder')}
|
||||||
|
className="min-h-[40px]"
|
||||||
|
autoComplete="off"
|
||||||
|
value={field.value}
|
||||||
|
onFocus={() => setFolderDropdownOpen(true)}
|
||||||
|
onChange={e => {
|
||||||
|
field.onChange(e);
|
||||||
|
setFolderDropdownOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{folderDropdownOpen && filteredFolders.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={folderDropdownRef}
|
||||||
|
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-1 p-0">
|
||||||
|
{filteredFolders.map((folder) => (
|
||||||
|
<Button
|
||||||
|
key={folder}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||||
|
onClick={() => handleFolderClick(folder)}
|
||||||
|
>
|
||||||
|
{folder}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-10 overflow-visible">
|
||||||
|
<FormLabel>{t('credentials.tags')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<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]">
|
||||||
|
{field.value.map((tag: string, idx: number) => (
|
||||||
|
<span key={tag + idx}
|
||||||
|
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
const newTags = field.value.filter((_: string, i: number) => i !== idx);
|
||||||
|
field.onChange(newTags);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={e => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === " " && tagInput.trim() !== "") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!field.value.includes(tagInput.trim())) {
|
||||||
|
field.onChange([...field.value, tagInput.trim()]);
|
||||||
|
}
|
||||||
|
setTagInput("");
|
||||||
|
} else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) {
|
||||||
|
field.onChange(field.value.slice(0, -1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('credentials.addTagsSpaceToAdd')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="authentication">
|
||||||
|
<FormLabel className="mb-3 font-bold">{t('credentials.authentication')}</FormLabel>
|
||||||
|
<Tabs
|
||||||
|
value={authTab}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setAuthTab(value as 'password' | 'key');
|
||||||
|
form.setValue('authType', value as 'password' | 'key');
|
||||||
|
// Clear other auth fields when switching
|
||||||
|
if (value === 'password') {
|
||||||
|
form.setValue('key', null);
|
||||||
|
form.setValue('keyPassword', '');
|
||||||
|
} else if (value === 'key') {
|
||||||
|
form.setValue('password', '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="password">{t('credentials.password')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="key">{t('credentials.key')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="password">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('credentials.password')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder={t('placeholders.password')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="key">
|
||||||
|
<div className="grid grid-cols-15 gap-4">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-4 overflow-hidden min-w-0">
|
||||||
|
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<input
|
||||||
|
id="key-upload"
|
||||||
|
type="file"
|
||||||
|
accept=".pem,.key,.txt,.ppk"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
field.onChange(file || null);
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="block w-full truncate"
|
||||||
|
title={field.value?.name || t('credentials.upload')}>
|
||||||
|
{field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keyPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-8">
|
||||||
|
<FormLabel>{t('credentials.keyPassword')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t('placeholders.keyPassword')}
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keyType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative col-span-3">
|
||||||
|
<FormLabel>{t('credentials.keyType')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
ref={keyTypeButtonRef}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
|
||||||
|
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')}
|
||||||
|
</Button>
|
||||||
|
{keyTypeDropdownOpen && (
|
||||||
|
<div
|
||||||
|
ref={keyTypeDropdownRef}
|
||||||
|
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-1 p-0">
|
||||||
|
{keyTypeOptions.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(opt.value);
|
||||||
|
setKeyTypeDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</ScrollArea>
|
||||||
|
<footer className="shrink-0 w-full pb-0">
|
||||||
|
<Separator className="p-0.25"/>
|
||||||
|
<Button
|
||||||
|
className=""
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
style={{
|
||||||
|
transform: 'translateY(8px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editingCredential ? t('credentials.updateCredential') : t('credentials.addCredential')}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
482
src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx
Normal file
482
src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
Key,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Hash,
|
||||||
|
Folder,
|
||||||
|
Edit3,
|
||||||
|
Copy,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Clock,
|
||||||
|
Server,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
ExternalLink,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
FileText
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
folder?: string;
|
||||||
|
tags: string[];
|
||||||
|
authType: 'password' | 'key';
|
||||||
|
username: string;
|
||||||
|
keyType?: string;
|
||||||
|
usageCount: number;
|
||||||
|
lastUsed?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialWithSecrets extends Credential {
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostInfo {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialViewerProps {
|
||||||
|
credential: Credential;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [credentialDetails, setCredentialDetails] = useState<CredentialWithSecrets | null>(null);
|
||||||
|
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'security' | 'usage'>('overview');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredentialDetails();
|
||||||
|
fetchHostsUsing();
|
||||||
|
}, [credential.id]);
|
||||||
|
|
||||||
|
const fetchCredentialDetails = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getCredentialDetails(credential.id);
|
||||||
|
setCredentialDetails(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch credential details:', error);
|
||||||
|
toast.error(t('credentials.failedToFetchCredentialDetails'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchHostsUsing = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getCredentialHosts(credential.id);
|
||||||
|
setHostsUsing(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch hosts using credential:', error);
|
||||||
|
toast.error(t('credentials.failedToFetchHostsUsing'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSensitiveVisibility = (field: string) => {
|
||||||
|
setShowSensitive(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: !prev[field]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(t('copiedToClipboard', { field: fieldName }));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('credentials.failedToCopy'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthIcon = (authType: string) => {
|
||||||
|
return authType === 'password' ? (
|
||||||
|
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
) : (
|
||||||
|
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSensitiveField = (
|
||||||
|
value: string | undefined,
|
||||||
|
fieldName: string,
|
||||||
|
label: string,
|
||||||
|
isMultiline = false
|
||||||
|
) => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const isVisible = showSensitive[fieldName];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleSensitiveVisibility(fieldName)}
|
||||||
|
>
|
||||||
|
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copyToClipboard(value, label)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
|
||||||
|
{isVisible ? (
|
||||||
|
<pre className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
|
||||||
|
{value}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{'•'.repeat(isMultiline ? 50 : 20)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !credentialDetails) {
|
||||||
|
return (
|
||||||
|
<Sheet open={true} onOpenChange={onClose}>
|
||||||
|
<SheetContent className="w-[600px] max-w-[50vw]">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={true} onOpenChange={onClose}>
|
||||||
|
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
||||||
|
<SheetHeader className="space-y-6 pb-8">
|
||||||
|
<SheetTitle className="flex items-center space-x-4">
|
||||||
|
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
{getAuthIcon(credentialDetails.authType)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xl font-semibold">{credentialDetails.name}</div>
|
||||||
|
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||||
|
{credentialDetails.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge variant="outline" className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{credentialDetails.authType}
|
||||||
|
</Badge>
|
||||||
|
{credentialDetails.keyType && (
|
||||||
|
<Badge variant="secondary" className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||||
|
{credentialDetails.keyType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-10">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'overview' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab('overview')}
|
||||||
|
className="flex-1 h-10"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
{t('credentials.overview')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'security' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab('security')}
|
||||||
|
className="flex-1 h-10"
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
|
{t('credentials.security')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'usage' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab('usage')}
|
||||||
|
className="flex-1 h-10"
|
||||||
|
>
|
||||||
|
<Server className="h-4 w-4 mr-2" />
|
||||||
|
{t('credentials.usage')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
|
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||||
|
<CardHeader className="pb-8">
|
||||||
|
<CardTitle className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div>
|
||||||
|
<div className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{credentialDetails.folder && (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div>
|
||||||
|
<div className="font-medium">{credentialDetails.folder}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentialDetails.tags.length > 0 && (
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{credentialDetails.tags.map((tag, index) => (
|
||||||
|
<Badge key={index} variant="outline" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div>
|
||||||
|
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div>
|
||||||
|
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
|
||||||
|
{credentialDetails.usageCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{t('credentials.timesUsed')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{credentialDetails.lastUsed && (
|
||||||
|
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
|
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div>
|
||||||
|
<div className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
|
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
|
||||||
|
<div className="font-medium">{hostsUsing.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'security' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center space-x-2">
|
||||||
|
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
<span>{t('credentials.securityDetails')}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('credentials.securityDetailsDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
|
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{t('credentials.credentialSecured')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||||
|
{t('credentials.credentialSecuredDescription')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{credentialDetails.authType === 'password' && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">{t('credentials.passwordAuthentication')}</h3>
|
||||||
|
{renderSensitiveField(credentialDetails.password, 'password', t('common.password'))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentialDetails.authType === 'key' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||||
|
{t('credentials.keyType')}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-sm">
|
||||||
|
{credentialDetails.keyType?.toUpperCase() || t('unknown').toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)}
|
||||||
|
|
||||||
|
{credentialDetails.keyPassword && renderSensitiveField(
|
||||||
|
credentialDetails.keyPassword,
|
||||||
|
'keyPassword',
|
||||||
|
t('credentials.keyPassphrase')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
||||||
|
{t('credentials.securityReminder')}
|
||||||
|
</div>
|
||||||
|
<div className="text-zinc-700 dark:text-zinc-300">
|
||||||
|
{t('credentials.securityReminderText')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'usage' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center space-x-2">
|
||||||
|
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
<span>{t('credentials.hostsUsingCredential')}</span>
|
||||||
|
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{hostsUsing.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
|
||||||
|
<p>{t('credentials.noHostsUsingCredential')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{hostsUsing.map((host) => (
|
||||||
|
<div
|
||||||
|
key={host.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||||
|
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{host.name || `${host.ip}:${host.port}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{host.ip}:{host.port}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{formatDate(host.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onEdit}>
|
||||||
|
<Edit3 className="h-4 w-4 mr-2" />
|
||||||
|
{t('credentials.editCredential')}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CredentialViewer;
|
||||||
336
src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx
Normal file
336
src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Key,
|
||||||
|
Folder,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Shield,
|
||||||
|
Pin,
|
||||||
|
Tag,
|
||||||
|
Info
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getCredentials, deleteCredential } from '@/ui/main-axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {CredentialEditor} from './CredentialEditor';
|
||||||
|
import CredentialViewer from './CredentialViewer';
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
folder?: string;
|
||||||
|
tags: string[];
|
||||||
|
authType: 'password' | 'key';
|
||||||
|
username: string;
|
||||||
|
keyType?: string;
|
||||||
|
usageCount: number;
|
||||||
|
lastUsed?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialsManagerProps {
|
||||||
|
onEditCredential?: (credential: Credential) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showViewer, setShowViewer] = useState(false);
|
||||||
|
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredentials();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCredentials = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getCredentials();
|
||||||
|
setCredentials(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(t('credentials.failedToFetchCredentials'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleEdit = (credential: Credential) => {
|
||||||
|
if (onEditCredential) {
|
||||||
|
onEditCredential(credential);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||||
|
if (window.confirm(t('credentials.confirmDeleteCredential', { name: credentialName }))) {
|
||||||
|
try {
|
||||||
|
await deleteCredential(credentialId);
|
||||||
|
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
||||||
|
await fetchCredentials();
|
||||||
|
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('credentials.failedToDeleteCredential'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const filteredAndSortedCredentials = useMemo(() => {
|
||||||
|
let filtered = credentials;
|
||||||
|
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = credentials.filter(credential => {
|
||||||
|
const searchableText = [
|
||||||
|
credential.name || '',
|
||||||
|
credential.username,
|
||||||
|
credential.description || '',
|
||||||
|
...(credential.tags || []),
|
||||||
|
credential.authType,
|
||||||
|
credential.keyType || ''
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return searchableText.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
const aName = a.name || a.username;
|
||||||
|
const bName = b.name || b.username;
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
});
|
||||||
|
}, [credentials, searchQuery]);
|
||||||
|
|
||||||
|
const credentialsByFolder = useMemo(() => {
|
||||||
|
const grouped: { [key: string]: Credential[] } = {};
|
||||||
|
|
||||||
|
filteredAndSortedCredentials.forEach(credential => {
|
||||||
|
const folder = credential.folder || t('credentials.uncategorized');
|
||||||
|
if (!grouped[folder]) {
|
||||||
|
grouped[folder] = [];
|
||||||
|
}
|
||||||
|
grouped[folder].push(credential);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||||
|
if (a === t('credentials.uncategorized')) return -1;
|
||||||
|
if (b === t('credentials.uncategorized')) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedGrouped: { [key: string]: Credential[] } = {};
|
||||||
|
sortedFolders.forEach(folder => {
|
||||||
|
sortedGrouped[folder] = grouped[folder];
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedGrouped;
|
||||||
|
}, [filteredAndSortedCredentials, t]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||||
|
<p className="text-muted-foreground">{t('credentials.loadingCredentials')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
|
<Button onClick={fetchCredentials} variant="outline">
|
||||||
|
{t('credentials.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t('credentials.credentialsCount', { count: filteredAndSortedCredentials.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||||
|
{t('credentials.refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
|
<Input
|
||||||
|
placeholder={t('placeholders.searchCredentials')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
|
<div className="space-y-2 pb-20">
|
||||||
|
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
|
||||||
|
<div key={folder} className="border rounded-md">
|
||||||
|
<Accordion type="multiple" defaultValue={Object.keys(credentialsByFolder)}>
|
||||||
|
<AccordionItem value={folder} className="border-none">
|
||||||
|
<AccordionTrigger
|
||||||
|
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="h-4 w-4"/>
|
||||||
|
<span className="font-medium">{folder}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{folderCredentials.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="p-2">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
{folderCredentials.map((credential) => (
|
||||||
|
<div
|
||||||
|
key={credential.id}
|
||||||
|
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||||
|
onClick={() => handleEdit(credential)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<h3 className="font-medium truncate text-sm">
|
||||||
|
{credential.name || `${credential.username}`}
|
||||||
|
</h3>
|
||||||
|
</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"/>
|
||||||
|
</Button>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{credential.tags && credential.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{credential.tags.slice(0, 6).map((tag, index) => (
|
||||||
|
<Badge key={index} variant="outline"
|
||||||
|
className="text-xs px-1 py-0">
|
||||||
|
<Tag className="h-2 w-2 mr-0.5"/>
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{credential.tags.length > 6 && (
|
||||||
|
<Badge variant="outline"
|
||||||
|
className="text-xs px-1 py-0">
|
||||||
|
+{credential.tags.length - 6}
|
||||||
|
</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>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{showViewer && viewingCredential && (
|
||||||
|
<CredentialViewer
|
||||||
|
credential={viewingCredential}
|
||||||
|
onClose={() => setShowViewer(false)}
|
||||||
|
onEdit={() => {
|
||||||
|
setShowViewer(false);
|
||||||
|
handleEdit(viewingCredential);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, {useState, useEffect, useRef} from "react";
|
import React, {useState, useEffect, useRef} from "react";
|
||||||
import {FileManagerLeftSidebar} from "@/ui/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
import {FileManagerLeftSidebar} from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||||
import {FileManagerTabList} from "@/ui/Apps/File Manager/FileManagerTabList.tsx";
|
import {FileManagerTabList} from "@/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx";
|
||||||
import {FileManagerHomeView} from "@/ui/Apps/File Manager/FileManagerHomeView.tsx";
|
import {FileManagerHomeView} from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
|
||||||
import {FileManagerFileEditor} from "@/ui/Apps/File Manager/FileManagerFileEditor.tsx";
|
import {FileManagerFileEditor} from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
|
||||||
import {FileManagerOperations} from "@/ui/Apps/File Manager/FileManagerOperations.tsx";
|
import {FileManagerOperations} from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx";
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
import {Button} from '@/components/ui/button.tsx';
|
||||||
import {FIleManagerTopNavbar} from "@/ui/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
import {FIleManagerTopNavbar} from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||||
import {cn} from '@/lib/utils.ts';
|
import {cn} from '@/lib/utils.ts';
|
||||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||||
import {Separator} from '@/components/ui/separator.tsx';
|
import {Separator} from '@/components/ui/separator.tsx';
|
||||||
246
src/ui/Desktop/Apps/Host Manager/FolderManager.tsx
Normal file
246
src/ui/Desktop/Apps/Host Manager/FolderManager.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
Edit,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getFoldersWithStats, renameFolder } from '@/ui/main-axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface FolderStats {
|
||||||
|
name: string;
|
||||||
|
hostCount: number;
|
||||||
|
hosts: Array<{
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderManagerProps {
|
||||||
|
onFolderChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderManager({ onFolderChanged }: FolderManagerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [folders, setFolders] = useState<FolderStats[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Rename state
|
||||||
|
const [renameLoading, setRenameLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFolders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchFolders = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getFoldersWithStats();
|
||||||
|
setFolders(data || []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch folder statistics');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async (folder: FolderStats) => {
|
||||||
|
const newName = prompt(
|
||||||
|
`Enter new name for folder "${folder.name}":\n\nThis will update ${folder.hostCount} host(s) that use this folder.`,
|
||||||
|
folder.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newName || newName.trim() === '' || newName === folder.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.confirm(
|
||||||
|
`Are you sure you want to rename folder "${folder.name}" to "${newName.trim()}"?\n\n` +
|
||||||
|
`This will update ${folder.hostCount} host(s) that currently use this folder.`
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
setRenameLoading(true);
|
||||||
|
await renameFolder(folder.name, newName.trim());
|
||||||
|
toast.success(`Folder renamed from "${folder.name}" to "${newName.trim()}"`, {
|
||||||
|
description: `Updated ${folder.hostCount} host(s)`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh folder list
|
||||||
|
await fetchFolders();
|
||||||
|
|
||||||
|
// Notify parent component about folder change
|
||||||
|
if (onFolderChanged) {
|
||||||
|
onFolderChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event for other components to refresh
|
||||||
|
window.dispatchEvent(new CustomEvent('folders:changed'));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to rename folder');
|
||||||
|
} finally {
|
||||||
|
setRenameLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredFolders = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return folders.filter(folder =>
|
||||||
|
folder.name.toLowerCase().includes(query) ||
|
||||||
|
folder.hosts.some(host =>
|
||||||
|
(host.name?.toLowerCase().includes(query)) ||
|
||||||
|
host.ip.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [folders, searchQuery]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||||
|
<p className="text-muted-foreground">Loading folders...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
|
<Button onClick={fetchFolders} variant="outline">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<Folder className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Folders Found</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Create some hosts with folders to manage them here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Folder Management</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{filteredFolders.length} folder(s) found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={fetchFolders} variant="outline" size="sm">
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
|
<Input
|
||||||
|
placeholder="Search folders..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
|
<div className="space-y-3 pb-20">
|
||||||
|
{filteredFolders.map((folder) => (
|
||||||
|
<div
|
||||||
|
key={folder.name}
|
||||||
|
className="bg-[#222225] border border-input rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Folder className="h-5 w-5 text-blue-500" />
|
||||||
|
<h3 className="font-medium text-lg truncate">
|
||||||
|
{folder.name}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
<Users className="h-3 w-3 mr-1" />
|
||||||
|
{folder.hostCount} host(s)
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-shrink-0 ml-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRename(folder)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Rename folder"
|
||||||
|
disabled={renameLoading}
|
||||||
|
>
|
||||||
|
{renameLoading ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||||
|
) : (
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Hosts using this folder:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-1 max-h-32 overflow-y-auto">
|
||||||
|
{folder.hosts.slice(0, 5).map((host) => (
|
||||||
|
<div key={host.id} className="flex items-center gap-2 text-sm bg-muted/20 rounded px-2 py-1">
|
||||||
|
<span className="font-medium">
|
||||||
|
{host.name || host.ip}
|
||||||
|
</span>
|
||||||
|
{host.name && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({host.ip})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{folder.hosts.length > 5 && (
|
||||||
|
<div className="text-sm text-muted-foreground px-2 py-1">
|
||||||
|
... and {folder.hosts.length - 5} more host(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, {useState} from "react";
|
import React, {useState} from "react";
|
||||||
import {HostManagerHostViewer} from "@/ui/Apps/Host Manager/HostManagerHostViewer.tsx"
|
import {HostManagerHostViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx"
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import {HostManagerHostEditor} from "@/ui/Apps/Host Manager/HostManagerHostEditor.tsx";
|
import {HostManagerHostEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx";
|
||||||
|
import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
||||||
|
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
|||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||||
|
const [editingCredential, setEditingCredential] = useState<any | null>(null);
|
||||||
const {state: sidebarState} = useSidebar();
|
const {state: sidebarState} = useSidebar();
|
||||||
|
|
||||||
const handleEditHost = (host: SSHHost) => {
|
const handleEditHost = (host: SSHHost) => {
|
||||||
@@ -50,11 +53,25 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
|||||||
setActiveTab("host_viewer");
|
setActiveTab("host_viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditCredential = (credential: any) => {
|
||||||
|
setEditingCredential(credential);
|
||||||
|
setActiveTab("add_credential");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCredentialFormSubmit = () => {
|
||||||
|
setEditingCredential(null);
|
||||||
|
setActiveTab("credentials");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
if (value === "host_viewer") {
|
if (value === "host_viewer") {
|
||||||
setEditingHost(null);
|
setEditingHost(null);
|
||||||
}
|
}
|
||||||
|
if (value === "credentials") {
|
||||||
|
setEditingCredential(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
@@ -81,6 +98,10 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
|||||||
<TabsTrigger value="add_host">
|
<TabsTrigger value="add_host">
|
||||||
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
|
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="credentials">{t('credentials.credentialsManager')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="add_credential">
|
||||||
|
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||||
@@ -95,6 +116,21 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0">
|
||||||
|
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||||
|
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
||||||
|
<CredentialsManager onEditCredential={handleEditCredential} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0">
|
||||||
|
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<CredentialEditor
|
||||||
|
editingCredential={editingCredential}
|
||||||
|
onFormSubmit={handleCredentialFormSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form.tsx";
|
} from "@/components/ui/form.tsx";
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
import {ScrollArea} from "@/components/ui/scroll-area"
|
import {ScrollArea} from "@/components/ui/scroll-area.tsx"
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
@@ -22,6 +22,7 @@ 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} from '@/ui/main-axios.ts';
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {CredentialSelector} from "@/components/CredentialSelector.tsx";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -44,6 +45,7 @@ interface SSHHost {
|
|||||||
tunnelConnections: any[];
|
tunnelConnections: any[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
credentialId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SSHManagerHostEditorProps {
|
interface SSHManagerHostEditorProps {
|
||||||
@@ -58,7 +60,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
|
||||||
|
|
||||||
|
// Ref for the IP address input to manage focus
|
||||||
|
const ipInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -98,7 +103,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
folder: z.string().optional(),
|
folder: z.string().optional(),
|
||||||
tags: z.array(z.string().min(1)).default([]),
|
tags: z.array(z.string().min(1)).default([]),
|
||||||
pin: z.boolean().default(false),
|
pin: z.boolean().default(false),
|
||||||
authType: z.enum(['password', 'key']),
|
authType: z.enum(['password', 'key', 'credential']),
|
||||||
|
credentialId: z.number().optional().nullable(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
key: z.instanceof(File).optional().nullable(),
|
key: z.instanceof(File).optional().nullable(),
|
||||||
keyPassword: z.string().optional(),
|
keyPassword: z.string().optional(),
|
||||||
@@ -149,6 +155,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
path: ['keyType']
|
path: ['keyType']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (data.authType === 'credential') {
|
||||||
|
if (!data.credentialId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t('hosts.credentialRequired'),
|
||||||
|
path: ['credentialId']
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.tunnelConnections.forEach((connection, index) => {
|
data.tunnelConnections.forEach((connection, index) => {
|
||||||
@@ -174,7 +188,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
folder: editingHost?.folder || "",
|
folder: editingHost?.folder || "",
|
||||||
tags: editingHost?.tags || [],
|
tags: editingHost?.tags || [],
|
||||||
pin: editingHost?.pin || false,
|
pin: editingHost?.pin || false,
|
||||||
authType: (editingHost?.authType as 'password' | 'key') || "password",
|
authType: (editingHost?.authType as 'password' | 'key' | 'credential') || "password",
|
||||||
|
credentialId: editingHost?.credentialId || null,
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
@@ -189,7 +204,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingHost) {
|
if (editingHost) {
|
||||||
const defaultAuthType = editingHost.key ? 'key' : 'password';
|
const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
|
||||||
|
|
||||||
setAuthTab(defaultAuthType);
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
@@ -201,7 +216,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
folder: editingHost.folder || "",
|
folder: editingHost.folder || "",
|
||||||
tags: editingHost.tags || [],
|
tags: editingHost.tags || [],
|
||||||
pin: editingHost.pin || false,
|
pin: editingHost.pin || false,
|
||||||
authType: defaultAuthType,
|
authType: defaultAuthType as 'password' | 'key' | 'credential',
|
||||||
|
credentialId: editingHost.credentialId || null,
|
||||||
password: editingHost.password || "",
|
password: editingHost.password || "",
|
||||||
key: editingHost.key ? new File([editingHost.key], "key.pem") : null,
|
key: editingHost.key ? new File([editingHost.key], "key.pem") : null,
|
||||||
keyPassword: editingHost.keyPassword || "",
|
keyPassword: editingHost.keyPassword || "",
|
||||||
@@ -224,6 +240,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
tags: [],
|
tags: [],
|
||||||
pin: false,
|
pin: false,
|
||||||
authType: "password",
|
authType: "password",
|
||||||
|
credentialId: null,
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
@@ -237,6 +254,27 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
}
|
}
|
||||||
}, [editingHost, form]);
|
}, [editingHost, form]);
|
||||||
|
|
||||||
|
// Focus the IP address field when the component mounts or when editingHost changes
|
||||||
|
useEffect(() => {
|
||||||
|
const focusTimer = setTimeout(() => {
|
||||||
|
if (ipInputRef.current) {
|
||||||
|
ipInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(focusTimer);
|
||||||
|
}, []); // Focus on mount
|
||||||
|
|
||||||
|
// Also focus when editingHost changes (for tab switching)
|
||||||
|
useEffect(() => {
|
||||||
|
const focusTimer = setTimeout(() => {
|
||||||
|
if (ipInputRef.current) {
|
||||||
|
ipInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(focusTimer);
|
||||||
|
}, [editingHost]);
|
||||||
|
|
||||||
const onSubmit = async (data: any) => {
|
const onSubmit = async (data: any) => {
|
||||||
try {
|
try {
|
||||||
const formData = data as FormData;
|
const formData = data as FormData;
|
||||||
@@ -413,7 +451,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
<FormItem className="col-span-5">
|
<FormItem className="col-span-5">
|
||||||
<FormLabel>{t('hosts.ipAddress')}</FormLabel>
|
<FormLabel>{t('hosts.ipAddress')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={t('placeholders.ipAddress')} {...field} />
|
<Input
|
||||||
|
placeholder={t('placeholders.ipAddress')}
|
||||||
|
{...field}
|
||||||
|
ref={(e) => {
|
||||||
|
field.ref(e);
|
||||||
|
ipInputRef.current = e;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -574,14 +619,28 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
<Tabs
|
<Tabs
|
||||||
value={authTab}
|
value={authTab}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setAuthTab(value as 'password' | 'key');
|
setAuthTab(value as 'password' | 'key' | 'credential');
|
||||||
form.setValue('authType', value as 'password' | 'key');
|
form.setValue('authType', value as 'password' | 'key' | 'credential');
|
||||||
|
// Clear other auth fields when switching
|
||||||
|
if (value === 'password') {
|
||||||
|
form.setValue('key', null);
|
||||||
|
form.setValue('keyPassword', '');
|
||||||
|
form.setValue('credentialId', null);
|
||||||
|
} else if (value === 'key') {
|
||||||
|
form.setValue('password', '');
|
||||||
|
form.setValue('credentialId', null);
|
||||||
|
} else if (value === 'credential') {
|
||||||
|
form.setValue('password', '');
|
||||||
|
form.setValue('key', null);
|
||||||
|
form.setValue('keyPassword', '');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 flex flex-col h-full min-h-0"
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
>
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger>
|
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger>
|
||||||
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger>
|
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="credential">{t('hosts.credential')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="password">
|
<TabsContent value="password">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -696,6 +755,18 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="credential">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="credentialId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CredentialSelector
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="terminal">
|
<TabsContent value="terminal">
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, {useState, useEffect, useMemo} from "react";
|
import React, {useState, useEffect, useMemo, useRef} from "react";
|
||||||
import {Card, CardContent} from "@/components/ui/card";
|
import {Card, CardContent} from "@/components/ui/card.tsx";
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Badge} from "@/components/ui/badge";
|
import {Badge} from "@/components/ui/badge.tsx";
|
||||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
|
||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx";
|
||||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} 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 {
|
import {
|
||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
FileEdit,
|
FileEdit,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
Info
|
Info,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
Pencil
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
|
||||||
@@ -55,9 +58,30 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [draggedHost, setDraggedHost] = useState<SSHHost | 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(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
|
|
||||||
|
// Listen for refresh events from other components
|
||||||
|
const handleHostsRefresh = () => {
|
||||||
|
fetchHosts();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('hosts:refresh', handleHostsRefresh);
|
||||||
|
window.addEventListener('ssh-hosts:changed', handleHostsRefresh);
|
||||||
|
window.addEventListener('folders:changed', handleHostsRefresh);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hosts:refresh', handleHostsRefresh);
|
||||||
|
window.removeEventListener('ssh-hosts:changed', handleHostsRefresh);
|
||||||
|
window.removeEventListener('folders:changed', handleHostsRefresh);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchHosts = async () => {
|
const fetchHosts = async () => {
|
||||||
@@ -86,12 +110,155 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = (host: SSHHost) => {
|
||||||
|
const exportData = {
|
||||||
|
name: host.name,
|
||||||
|
ip: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
authType: host.authType,
|
||||||
|
folder: host.folder,
|
||||||
|
tags: host.tags,
|
||||||
|
pin: host.pin,
|
||||||
|
enableTerminal: host.enableTerminal,
|
||||||
|
enableTunnel: host.enableTunnel,
|
||||||
|
enableFileManager: host.enableFileManager,
|
||||||
|
defaultPath: host.defaultPath,
|
||||||
|
tunnelConnections: host.tunnelConnections,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${host.name || host.username + '@' + host.ip}-credentials.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success(`Exported credentials for ${host.name || host.username}@${host.ip}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleEdit = (host: SSHHost) => {
|
const handleEdit = (host: SSHHost) => {
|
||||||
if (onEditHost) {
|
if (onEditHost) {
|
||||||
onEditHost(host);
|
onEditHost(host);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromFolder = async (host: SSHHost) => {
|
||||||
|
if (window.confirm(t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }))) {
|
||||||
|
try {
|
||||||
|
setOperationLoading(true);
|
||||||
|
const updatedHost = { ...host, folder: '' };
|
||||||
|
await updateSSHHost(host.id, updatedHost);
|
||||||
|
toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` }));
|
||||||
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('hosts.failedToRemoveFromFolder'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderRename = async (oldName: string) => {
|
||||||
|
if (!editingFolderName.trim() || editingFolderName === oldName) {
|
||||||
|
setEditingFolder(null);
|
||||||
|
setEditingFolderName('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setOperationLoading(true);
|
||||||
|
await renameFolder(oldName, editingFolderName.trim());
|
||||||
|
toast.success(t('hosts.folderRenamed', { oldName, newName: editingFolderName.trim() }));
|
||||||
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
setEditingFolder(null);
|
||||||
|
setEditingFolderName('');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('hosts.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, host: SSHHost) => {
|
||||||
|
setDraggedHost(host);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', ''); // Required for Firefox
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedHost(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 (!draggedHost) return;
|
||||||
|
|
||||||
|
const newFolder = targetFolder === t('hosts.uncategorized') ? '' : targetFolder;
|
||||||
|
|
||||||
|
if (draggedHost.folder === newFolder) {
|
||||||
|
setDraggedHost(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setOperationLoading(true);
|
||||||
|
const updatedHost = { ...draggedHost, folder: newFolder };
|
||||||
|
await updateSSHHost(draggedHost.id, updatedHost);
|
||||||
|
toast.success(t('hosts.movedToFolder', {
|
||||||
|
name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`,
|
||||||
|
folder: targetFolder
|
||||||
|
}));
|
||||||
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('hosts.failedToMoveToFolder'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
setDraggedHost(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -217,13 +384,141 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hosts.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">
|
||||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3>
|
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground">
|
||||||
{t('hosts.noHostsMessage')}
|
{t('hosts.hostsCount', { count: 0 })}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="relative"
|
||||||
|
onClick={() => document.getElementById('json-import-input')?.click()}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
|
{importing ? t('hosts.importing') : t('hosts.importJson')}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom"
|
||||||
|
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('hosts.importJsonDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const sampleData = {
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
name: "Web Server - Production",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
port: 22,
|
||||||
|
username: "admin",
|
||||||
|
authType: "password",
|
||||||
|
password: "your_secure_password_here",
|
||||||
|
folder: "Production",
|
||||||
|
tags: ["web", "production", "nginx"],
|
||||||
|
pin: true,
|
||||||
|
enableTerminal: true,
|
||||||
|
enableTunnel: false,
|
||||||
|
enableFileManager: true,
|
||||||
|
defaultPath: "/var/www"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Database Server",
|
||||||
|
ip: "192.168.1.101",
|
||||||
|
port: 22,
|
||||||
|
username: "dbadmin",
|
||||||
|
authType: "key",
|
||||||
|
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||||
|
keyPassword: "optional_key_passphrase",
|
||||||
|
keyType: "ssh-ed25519",
|
||||||
|
folder: "Production",
|
||||||
|
tags: ["database", "production", "postgresql"],
|
||||||
|
pin: false,
|
||||||
|
enableTerminal: true,
|
||||||
|
enableTunnel: true,
|
||||||
|
enableFileManager: false,
|
||||||
|
tunnelConnections: [
|
||||||
|
{
|
||||||
|
sourcePort: 5432,
|
||||||
|
endpointPort: 5432,
|
||||||
|
endpointHost: "Web Server - Production",
|
||||||
|
maxRetries: 3,
|
||||||
|
retryInterval: 10,
|
||||||
|
autoStart: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sample-ssh-hosts.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('hosts.downloadSample')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
window.open('https://docs.termix.site/json-import', '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('hosts.formatGuide')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-2"/>
|
||||||
|
|
||||||
|
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||||
|
{t('hosts.refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="json-import-input"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleJsonImport}
|
||||||
|
style={{display: 'none'}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center flex-1">
|
||||||
|
<div className="text-center">
|
||||||
|
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
{t('hosts.noHostsMessage')}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
@@ -367,14 +662,90 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
<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(hostsByFolder).map(([folder, folderHosts]) => (
|
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||||
<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(hostsByFolder)}>
|
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
||||||
<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('hosts.uncategorized')) {
|
||||||
|
startFolderEdit(folder);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={folder !== t('hosts.uncategorized') ? 'Click to rename folder' : ''}
|
||||||
|
>
|
||||||
|
{folder}
|
||||||
|
</span>
|
||||||
|
{folder !== t('hosts.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">
|
||||||
{folderHosts.length}
|
{folderHosts.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -385,7 +756,12 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
{folderHosts.map((host) => (
|
{folderHosts.map((host) => (
|
||||||
<div
|
<div
|
||||||
key={host.id}
|
key={host.id}
|
||||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, host)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`bg-[#222225] border border-input rounded cursor-move hover:shadow-md transition-all p-2 ${
|
||||||
|
draggedHost?.id === host.id ? 'opacity-50 scale-95' : ''
|
||||||
|
}`}
|
||||||
onClick={() => handleEdit(host)}
|
onClick={() => handleEdit(host)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -405,6 +781,21 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
</p>
|
</p>
|
||||||
</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 !== '' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveFromFolder(host);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700"
|
||||||
|
title={`Remove from folder "${host.folder}"`}
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -427,6 +818,18 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3"/>
|
<Trash2 className="h-3 w-3"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Upload className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {useSidebar} from "@/components/ui/sidebar";
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Progress} from "@/components/ui/progress"
|
import {Progress} from "@/components/ui/progress.tsx"
|
||||||
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||||
import {Tunnel} from "@/ui/Apps/Tunnel/Tunnel.tsx";
|
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/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
interface ServerProps {
|
interface ServerProps {
|
||||||
@@ -279,9 +279,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development' &&
|
const isDev = process.env.NODE_ENV === 'development' &&
|
||||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
(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
|
const wsUrl = isDev
|
||||||
? 'ws://localhost:8082'
|
? 'ws://localhost:8082'
|
||||||
|
: isElectron
|
||||||
|
? '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);
|
const ws = new WebSocket(wsUrl);
|
||||||
@@ -357,7 +361,7 @@ style.innerHTML = `
|
|||||||
/* Load NerdFonts locally */
|
/* Load NerdFonts locally */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrains Mono Nerd Font';
|
font-family: 'JetBrains Mono Nerd Font';
|
||||||
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -365,7 +369,7 @@ style.innerHTML = `
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrains Mono Nerd Font';
|
font-family: 'JetBrains Mono Nerd Font';
|
||||||
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -373,7 +377,7 @@ style.innerHTML = `
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrains Mono Nerd Font';
|
font-family: 'JetBrains Mono Nerd Font';
|
||||||
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, {useState, useEffect, useCallback} from "react";
|
import React, {useState, useEffect, useCallback} from "react";
|
||||||
import {TunnelViewer} from "@/ui/Apps/Tunnel/TunnelViewer.tsx";
|
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
||||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface TunnelConnection {
|
interface TunnelConnection {
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, {useState, useEffect} from "react"
|
import React, {useState, useEffect} from "react"
|
||||||
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
|
import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx"
|
||||||
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
|
import {Homepage} from "@/ui/Desktop/Homepage/Homepage.tsx"
|
||||||
import {AppView} from "@/ui/Navigation/AppView.tsx"
|
import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx"
|
||||||
import {HostManager} from "@/ui/Apps/Host Manager/HostManager.tsx"
|
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"
|
||||||
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
|
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"
|
||||||
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
|
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx";
|
||||||
import { AdminSettings } from "@/ui/Admin/AdminSettings";
|
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
||||||
import { UserProfile } from "@/ui/User/UserProfile.tsx";
|
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
@@ -217,7 +217,7 @@ function AppContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function DesktopApp() {
|
||||||
return (
|
return (
|
||||||
<TabProvider>
|
<TabProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
@@ -225,4 +225,4 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default DesktopApp
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
|
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
|
||||||
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
|
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
|
||||||
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
|
import {HomepageAlertManager} from "@/ui/Desktop/Homepage/HomepageAlertManager.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
|
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, {useState, useEffect} from "react";
|
import React, {useState, useEffect} from "react";
|
||||||
import {Eye, EyeOff} from "lucide-react";
|
import {Eye, EyeOff} from "lucide-react";
|
||||||
import {cn} from "../../lib/utils.ts";
|
import {cn} from "@/lib/utils.ts";
|
||||||
import {Button} from "../../components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Input} from "../../components/ui/input.tsx";
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
import {Label} from "../../components/ui/label.tsx";
|
import {Label} from "@/components/ui/label.tsx";
|
||||||
import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
|
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
import {LanguageSwitcher} from "../../components/LanguageSwitcher";
|
import {LanguageSwitcher} from "@/components/LanguageSwitcher.tsx";
|
||||||
import {
|
import {
|
||||||
registerUser,
|
registerUser,
|
||||||
loginUser,
|
loginUser,
|
||||||
@@ -18,13 +18,9 @@ import {
|
|||||||
verifyPasswordResetCode,
|
verifyPasswordResetCode,
|
||||||
completePasswordReset,
|
completePasswordReset,
|
||||||
getOIDCAuthorizeUrl,
|
getOIDCAuthorizeUrl,
|
||||||
verifyTOTPLogin
|
verifyTOTPLogin,
|
||||||
} from "../main-axios.ts";
|
setCookie
|
||||||
|
} from "../../main-axios.ts";
|
||||||
function setCookie(name: string, value: string, days = 7) {
|
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
import {Terminal} from "@/ui/Apps/Terminal/Terminal.tsx";
|
import {Terminal} from "@/ui/Desktop/Apps/Terminal/Terminal.tsx";
|
||||||
import {Server as ServerView} from "@/ui/Apps/Server/Server.tsx";
|
import {Server as ServerView} from "@/ui/Desktop/Apps/Server/Server.tsx";
|
||||||
import {FileManager} from "@/ui/Apps/File Manager/FileManager.tsx";
|
import {FileManager} from "@/ui/Desktop/Apps/File Manager/FileManager.tsx";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||||
import * as ResizablePrimitive from "react-resizable-panels";
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
@@ -2,7 +2,7 @@ import React, {useState} from "react";
|
|||||||
import {CardTitle} from "@/components/ui/card.tsx";
|
import {CardTitle} from "@/components/ui/card.tsx";
|
||||||
import {ChevronDown, Folder} from "lucide-react";
|
import {ChevronDown, Folder} from "lucide-react";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Host} from "@/ui/Navigation/Hosts/Host.tsx";
|
import {Host} from "@/ui/Desktop/Navigation/Hosts/Host.tsx";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
@@ -3,7 +3,7 @@ import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
|||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
import {Server, Terminal} from "lucide-react";
|
import {Server, Terminal} from "lucide-react";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
SheetClose
|
SheetClose
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet.tsx";
|
||||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
import {Label} from "@/components/ui/label.tsx";
|
import {Label} from "@/components/ui/label.tsx";
|
||||||
@@ -47,9 +47,9 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table.tsx";
|
} from "@/components/ui/table.tsx";
|
||||||
import {Card} from "@/components/ui/card.tsx";
|
import {Card} from "@/components/ui/card.tsx";
|
||||||
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
import {FolderCard} from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
|
||||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import { deleteAccount } from "@/ui/main-axios.ts";
|
import { deleteAccount } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
@@ -388,6 +388,7 @@ export function LeftSidebar({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
onClick={handleLogout}>
|
onClick={handleLogout}>
|
||||||
|
|
||||||
<span>{t('common.logout')}</span>
|
<span>{t('common.logout')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
111
src/ui/Desktop/Navigation/Tabs/TabDropdown.tsx
Normal file
111
src/ui/Desktop/Navigation/Tabs/TabDropdown.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu.tsx";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Home,
|
||||||
|
Terminal as TerminalIcon,
|
||||||
|
Server as ServerIcon,
|
||||||
|
Folder as FolderIcon,
|
||||||
|
Shield as AdminIcon,
|
||||||
|
Network as SshManagerIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTabs, type Tab } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function TabDropdown(): React.ReactElement {
|
||||||
|
const { tabs, currentTab, setCurrentTab } = useTabs();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getTabIcon = (tabType: Tab['type']) => {
|
||||||
|
switch (tabType) {
|
||||||
|
case 'home':
|
||||||
|
return <Home className="h-4 w-4" />;
|
||||||
|
case 'terminal':
|
||||||
|
return <TerminalIcon className="h-4 w-4" />;
|
||||||
|
case 'server':
|
||||||
|
return <ServerIcon className="h-4 w-4" />;
|
||||||
|
case 'file_manager':
|
||||||
|
return <FolderIcon className="h-4 w-4" />;
|
||||||
|
case 'ssh_manager':
|
||||||
|
return <SshManagerIcon className="h-4 w-4" />;
|
||||||
|
case 'admin':
|
||||||
|
return <AdminIcon className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <TerminalIcon className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTabDisplayTitle = (tab: Tab) => {
|
||||||
|
switch (tab.type) {
|
||||||
|
case 'home':
|
||||||
|
return t('nav.home');
|
||||||
|
case 'server':
|
||||||
|
return tab.title || t('nav.serverStats');
|
||||||
|
case 'file_manager':
|
||||||
|
return tab.title || t('nav.fileManager');
|
||||||
|
case 'ssh_manager':
|
||||||
|
return tab.title || t('nav.sshManager');
|
||||||
|
case 'admin':
|
||||||
|
return tab.title || t('nav.admin');
|
||||||
|
case 'terminal':
|
||||||
|
default:
|
||||||
|
return tab.title || t('nav.terminal');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabSwitch = (tabId: number) => {
|
||||||
|
setCurrentTab(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If only one tab (home), don't show dropdown
|
||||||
|
if (tabs.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-[30px] h-[30px] border-[#303032]"
|
||||||
|
title={t('nav.tabNavigation', { defaultValue: 'Tab Navigation' })}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-56 bg-[#18181b] border-[#303032] text-white"
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === currentTab;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => handleTabSwitch(tab.id)}
|
||||||
|
className={`flex items-center gap-2 cursor-pointer px-3 py-2 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#1d1d1f] text-white'
|
||||||
|
: 'hover:bg-[#2d2d30] text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getTabIcon(tab.type)}
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{getTabDisplayTitle(tab)}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, {useState} from "react";
|
import React, {useState} from "react";
|
||||||
import {useSidebar} from "@/components/ui/sidebar";
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react";
|
import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react";
|
||||||
import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx";
|
import {Tab} from "@/ui/Desktop/Navigation/Tabs/Tab.tsx";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -14,6 +14,7 @@ import {Input} from "@/components/ui/input.tsx";
|
|||||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {TabDropdown} from "@/ui/Desktop/Navigation/Tabs/TabDropdown.tsx";
|
||||||
|
|
||||||
interface TopNavbarProps {
|
interface TopNavbarProps {
|
||||||
isTopbarOpen: boolean;
|
isTopbarOpen: boolean;
|
||||||
@@ -59,13 +60,9 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
setSelectedTabIds([]);
|
setSelectedTabIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (selectedTabIds.length === 0) return;
|
if (selectedTabIds.length === 0) return;
|
||||||
|
|
||||||
const value = e.currentTarget.value;
|
|
||||||
let commandToSend = '';
|
let commandToSend = '';
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -266,6 +263,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||||
|
<TabDropdown />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-[30px] h-[30px]"
|
className="w-[30px] h-[30px]"
|
||||||
@@ -19,7 +19,6 @@ interface PasswordResetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordReset({userInfo}: PasswordResetProps) {
|
export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||||
const {t} = useTranslation();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
|
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
|
||||||
@@ -28,6 +27,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [tempToken, setTempToken] = useState("");
|
const [tempToken, setTempToken] = useState("");
|
||||||
const [resetLoading, setResetLoading] = useState(false);
|
const [resetLoading, setResetLoading] = useState(false);
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
async function handleInitiatePasswordReset() {
|
async function handleInitiatePasswordReset() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -168,7 +168,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
|||||||
setResetCode("");
|
setResetCode("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('common.back')}
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -225,14 +225,14 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
|||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('common.back')}
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="mt-4">
|
<Alert variant="destructive" className="mt-4">
|
||||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
<AlertTitle>Error</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -6,12 +6,14 @@ import {Label} from "@/components/ui/label.tsx";
|
|||||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||||
import {User, Shield, Key, AlertCircle} from "lucide-react";
|
import {User, Shield, Key, AlertCircle} from "lucide-react";
|
||||||
import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx";
|
import {TOTPSetup} from "@/ui/Desktop/User/TOTPSetup.tsx";
|
||||||
import {getUserInfo} from "@/ui/main-axios.ts";
|
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||||
|
import {getVersionInfo} from "@/ui/main-axios.ts";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {PasswordReset} from "@/ui/User/PasswordReset.tsx";
|
import {PasswordReset} from "@/ui/Desktop/User/PasswordReset.tsx";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
import {LanguageSwitcher} from "@/components/LanguageSwitcher";
|
import {LanguageSwitcher} from "@/components/LanguageSwitcher.tsx";
|
||||||
|
|
||||||
|
|
||||||
interface UserProfileProps {
|
interface UserProfileProps {
|
||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
@@ -27,11 +29,23 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
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 [versionInfo, setVersionInfo] = useState<{ version: string } | null>(null);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserInfo();
|
fetchUserInfo();
|
||||||
|
fetchVersion();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchVersion = async () => {
|
||||||
|
try {
|
||||||
|
const info = await getVersionInfo();
|
||||||
|
setVersionInfo({version: info.localVersion});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load version info", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchUserInfo = async () => {
|
const fetchUserInfo = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -146,15 +160,22 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('common.version')}</Label>
|
||||||
|
<p className="text-lg font-medium mt-1">
|
||||||
|
{versionInfo?.version || t('common.loading')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t">
|
<div className="mt-6 pt-6 border-t">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>{t('common.language')}</Label>
|
<Label>{t('common.language')}</Label>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{t('profile.selectPreferredLanguage')}</p>
|
<p className="text-sm text-muted-foreground mt-1">{t('profile.selectPreferredLanguage')}</p>
|
||||||
</div>
|
</div>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
46
src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx
Normal file
46
src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Menu, X, Terminal as TerminalIcon} from "lucide-react";
|
||||||
|
import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx";
|
||||||
|
import {cn} from "@/lib/utils.ts";
|
||||||
|
|
||||||
|
interface MenuProps {
|
||||||
|
onSidebarOpenClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomNavbar({onSidebarOpenClick}: MenuProps) {
|
||||||
|
const {tabs, currentTab, setCurrentTab, removeTab} = useTabs();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[80px] bg-[#18181B] flex items-center p-2 gap-2">
|
||||||
|
<Button className="w-[40px] h-[40px] flex-shrink-0" variant="outline" onClick={onSidebarOpenClick}>
|
||||||
|
<Menu/>
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 overflow-x-auto whitespace-nowrap thin-scrollbar">
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-10 rounded-r-none !px-3 border-1 border-[#303032]",
|
||||||
|
tab.id === currentTab && '!bg-[#09090b] !text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentTab(tab.id)}
|
||||||
|
>
|
||||||
|
<TerminalIcon className="mr-1 h-4 w-4"/>
|
||||||
|
{tab.title}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 rounded-l-none !px-2 border-1 border-[#303032]"
|
||||||
|
onClick={() => removeTab(tab.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx
Normal file
81
src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, {useState} from "react";
|
||||||
|
import {CardTitle} from "@/components/ui/card.tsx";
|
||||||
|
import {ChevronDown, Folder} from "lucide-react";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import {Host} from "@/ui/Mobile/Apps/Navigation/Hosts/Host.tsx";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderCardProps {
|
||||||
|
folderName: string;
|
||||||
|
hosts: SSHHost[];
|
||||||
|
onHostConnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderCard({folderName, hosts, onHostConnect}: FolderCardProps): React.ReactElement {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||||
|
style={{padding: '0', margin: '0'}}>
|
||||||
|
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||||
|
<div className="flex gap-2 pr-10">
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
<Folder size={16} strokeWidth={3}/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
>
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="flex flex-col p-2 gap-y-3">
|
||||||
|
{hosts.map((host, index) => (
|
||||||
|
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||||
|
<Host host={host} onHostConnect={onHostConnect}/>
|
||||||
|
|
||||||
|
{index < hosts.length - 1 && (
|
||||||
|
<div className="relative -mx-2">
|
||||||
|
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx
Normal file
107
src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
|
import {Server, Terminal} from "lucide-react";
|
||||||
|
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||||
|
import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostProps {
|
||||||
|
host: SSHHost;
|
||||||
|
onHostConnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Host({host, onHostConnect}: HostProps): React.ReactElement {
|
||||||
|
const {addTab} = useTabs();
|
||||||
|
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded');
|
||||||
|
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||||
|
const hasTags = tags.length > 0;
|
||||||
|
|
||||||
|
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId: number | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getServerStatusById(host.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setServerStatus('offline');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStatus();
|
||||||
|
|
||||||
|
intervalId = window.setInterval(fetchStatus, 10000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [host.id]);
|
||||||
|
|
||||||
|
const handleTerminalClick = () => {
|
||||||
|
addTab({type: 'terminal', title, hostConfig: host});
|
||||||
|
onHostConnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||||
|
<StatusIndicator/>
|
||||||
|
</Status>
|
||||||
|
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||||
|
{host.name || host.ip}
|
||||||
|
</p>
|
||||||
|
<ButtonGroup className="flex-shrink-0">
|
||||||
|
{host.enableTerminal && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 border-1 w-[60px] border-[#303032]"
|
||||||
|
onClick={handleTerminalClick}
|
||||||
|
>
|
||||||
|
<Terminal/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
{hasTags && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
{tags.map((tag: string) => (
|
||||||
|
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
|
||||||
|
<p className="text-sm">{tag}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx
Normal file
227
src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||||
|
SidebarProvider
|
||||||
|
} from "@/components/ui/sidebar.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {ChevronUp, Menu, User2} from "lucide-react";
|
||||||
|
import React, {useState, useEffect, useMemo, useCallback} from "react";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import {FolderCard} from "@/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx";
|
||||||
|
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
|
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeftSidebarProps {
|
||||||
|
isSidebarOpen: boolean;
|
||||||
|
setIsSidebarOpen: (type: boolean) => void;
|
||||||
|
onHostConnect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftSidebar({isSidebarOpen, setIsSidebarOpen, onHostConnect, disabled, username}: LeftSidebarProps) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
|
const [hostsLoading, setHostsLoading] = useState(false);
|
||||||
|
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||||
|
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
|
||||||
|
const fetchHosts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const newHosts = await getSSHHosts();
|
||||||
|
const prevHosts = prevHostsRef.current;
|
||||||
|
|
||||||
|
if (JSON.stringify(newHosts) !== JSON.stringify(prevHosts)) {
|
||||||
|
setHosts(newHosts);
|
||||||
|
prevHostsRef.current = newHosts;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setHostsError(t('leftSidebar.failedToLoadHosts'));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHosts();
|
||||||
|
const interval = setInterval(fetchHosts, 300000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchHosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleHostsChanged = () => {
|
||||||
|
fetchHosts();
|
||||||
|
};
|
||||||
|
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
|
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
|
}, [fetchHosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const filteredHosts = useMemo(() => {
|
||||||
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
|
return hosts.filter(h => {
|
||||||
|
const searchableText = [
|
||||||
|
h.name || '',
|
||||||
|
h.username,
|
||||||
|
h.ip,
|
||||||
|
h.folder || '',
|
||||||
|
...(h.tags || []),
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return searchableText.includes(q);
|
||||||
|
});
|
||||||
|
}, [hosts, debouncedSearch]);
|
||||||
|
|
||||||
|
const hostsByFolder = useMemo(() => {
|
||||||
|
const map: Record<string, SSHHost[]> = {};
|
||||||
|
filteredHosts.forEach(h => {
|
||||||
|
const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder');
|
||||||
|
if (!map[folder]) map[folder] = [];
|
||||||
|
map[folder].push(h);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [filteredHosts, t]);
|
||||||
|
|
||||||
|
const sortedFolders = useMemo(() => {
|
||||||
|
const folders = Object.keys(hostsByFolder);
|
||||||
|
folders.sort((a, b) => {
|
||||||
|
if (a === t('leftSidebar.noFolder')) return 1;
|
||||||
|
if (b === t('leftSidebar.noFolder')) return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
return folders;
|
||||||
|
}, [hostsByFolder, t]);
|
||||||
|
|
||||||
|
const getSortedHosts = useCallback((arr: SSHHost[]) => {
|
||||||
|
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
return [...pinned, ...rest];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<SidebarProvider open={isSidebarOpen}>
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||||
|
Termix
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
className="w-[28px] h-[28px] absolute right-5"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
</SidebarHeader>
|
||||||
|
<Separator/>
|
||||||
|
<SidebarContent className="px-2 py-2">
|
||||||
|
<div className="!bg-[#222225] rounded-lg mb-2">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder={t('placeholders.searchHostsAny')}
|
||||||
|
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hostsError && (
|
||||||
|
<div className="px-1">
|
||||||
|
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||||
|
{t('leftSidebar.failedToLoadHosts')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hostsLoading && (
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
{t('hosts.loadingHosts')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedFolders.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={`folder-${folder}`}
|
||||||
|
folderName={folder}
|
||||||
|
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||||
|
onHostConnect={onHostConnect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
<Separator className="mt-1"/>
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
className="data-[state=open]:opacity-90 w-full"
|
||||||
|
style={{width: '100%'}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<User2/> {username ? username : t('common.logout')}
|
||||||
|
<ChevronUp className="ml-auto"/>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
|
onClick={handleLogout}>
|
||||||
|
|
||||||
|
<span>{t('common.logout')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
</SidebarProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx
Normal file
100
src/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: number;
|
||||||
|
type: 'terminal';
|
||||||
|
title: string;
|
||||||
|
hostConfig?: any;
|
||||||
|
terminalRef?: React.RefObject<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabContextType {
|
||||||
|
tabs: Tab[];
|
||||||
|
currentTab: number | null;
|
||||||
|
addTab: (tab: Omit<Tab, 'id'>) => number;
|
||||||
|
removeTab: (tabId: number) => void;
|
||||||
|
setCurrentTab: (tabId: number) => void;
|
||||||
|
getTab: (tabId: number) => Tab | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function useTabs() {
|
||||||
|
const context = useContext(TabContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTabs must be used within a TabProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabProvider({children}: TabProviderProps) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||||
|
const [currentTab, setCurrentTab] = useState<number | null>(null);
|
||||||
|
const nextTabId = useRef(1);
|
||||||
|
|
||||||
|
function computeUniqueTitle(desiredTitle: string | undefined): string {
|
||||||
|
const baseTitle = (desiredTitle || 'Terminal').trim();
|
||||||
|
const existingTitles = tabs.map(t => t.title);
|
||||||
|
if (!existingTitles.includes(baseTitle)) {
|
||||||
|
return baseTitle;
|
||||||
|
}
|
||||||
|
let i = 2;
|
||||||
|
while (existingTitles.includes(`${baseTitle} (${i})`)) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${baseTitle} (${i})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTab = (tabData: Omit<Tab, 'id'>): number => {
|
||||||
|
const id = nextTabId.current++;
|
||||||
|
const newTab: Tab = {
|
||||||
|
...tabData,
|
||||||
|
id,
|
||||||
|
title: computeUniqueTitle(tabData.title),
|
||||||
|
terminalRef: React.createRef<any>()
|
||||||
|
};
|
||||||
|
setTabs(prev => [...prev, newTab]);
|
||||||
|
setCurrentTab(id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTab = (tabId: number) => {
|
||||||
|
const tab = tabs.find(t => t.id === tabId);
|
||||||
|
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
|
||||||
|
tab.terminalRef.current.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabs(prev => {
|
||||||
|
const newTabs = prev.filter(tab => tab.id !== tabId);
|
||||||
|
if (currentTab === tabId) {
|
||||||
|
setCurrentTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
|
||||||
|
}
|
||||||
|
return newTabs;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTab = (tabId: number) => {
|
||||||
|
return tabs.find(tab => tab.id === tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: TabContextType = {
|
||||||
|
tabs,
|
||||||
|
currentTab,
|
||||||
|
addTab,
|
||||||
|
removeTab,
|
||||||
|
setCurrentTab,
|
||||||
|
getTab,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</TabContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
339
src/ui/Mobile/Apps/Terminal/Terminal.tsx
Normal file
339
src/ui/Mobile/Apps/Terminal/Terminal.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
|
||||||
|
import {useXTerm} from 'react-xtermjs';
|
||||||
|
import {FitAddon} from '@xterm/addon-fit';
|
||||||
|
import {ClipboardAddon} from '@xterm/addon-clipboard';
|
||||||
|
import {Unicode11Addon} from '@xterm/addon-unicode11';
|
||||||
|
import {WebLinksAddon} from '@xterm/addon-web-links';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
interface SSHTerminalProps {
|
||||||
|
hostConfig: any;
|
||||||
|
isVisible: boolean;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||||
|
{hostConfig, isVisible},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||||
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
|
const webSocketRef = useRef<WebSocket | null>(null);
|
||||||
|
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const wasDisconnectedBySSH = useRef(false);
|
||||||
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
|
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
|
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const DEBOUNCE_MS = 140;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isVisibleRef.current = isVisible;
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
|
function hardRefresh() {
|
||||||
|
try {
|
||||||
|
if (terminal && typeof (terminal as any).refresh === 'function') {
|
||||||
|
(terminal as any).refresh(0, terminal.rows - 1);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNotify(cols: number, rows: number) {
|
||||||
|
if (!(cols > 0 && rows > 0)) return;
|
||||||
|
pendingSizeRef.current = {cols, rows};
|
||||||
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
|
notifyTimerRef.current = setTimeout(() => {
|
||||||
|
const next = pendingSizeRef.current;
|
||||||
|
const last = lastSentSizeRef.current;
|
||||||
|
if (!next) return;
|
||||||
|
if (last && last.cols === next.cols && last.rows === next.rows) return;
|
||||||
|
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
webSocketRef.current.send(JSON.stringify({type: 'resize', data: next}));
|
||||||
|
lastSentSizeRef.current = next;
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
disconnect: () => {
|
||||||
|
if (pingIntervalRef.current) {
|
||||||
|
clearInterval(pingIntervalRef.current);
|
||||||
|
pingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
webSocketRef.current?.close();
|
||||||
|
},
|
||||||
|
fit: () => {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
|
},
|
||||||
|
sendInput: (data: string) => {
|
||||||
|
if (webSocketRef.current?.readyState === 1) {
|
||||||
|
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notifyResize: () => {
|
||||||
|
try {
|
||||||
|
const cols = terminal?.cols ?? undefined;
|
||||||
|
const rows = terminal?.rows ?? undefined;
|
||||||
|
if (typeof cols === 'number' && typeof rows === 'number') {
|
||||||
|
scheduleNotify(cols, rows);
|
||||||
|
hardRefresh();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refresh: () => hardRefresh(),
|
||||||
|
}), [terminal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('resize', handleWindowResize);
|
||||||
|
return () => window.removeEventListener('resize', handleWindowResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleWindowResize() {
|
||||||
|
if (!isVisibleRef.current) return;
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||||
|
terminal.onData((data) => {
|
||||||
|
ws.send(JSON.stringify({type: 'input', data}));
|
||||||
|
});
|
||||||
|
|
||||||
|
pingIntervalRef.current = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({type: 'ping'}));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'data') terminal.write(msg.data);
|
||||||
|
else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`);
|
||||||
|
else if (msg.type === 'connected') {
|
||||||
|
} else if (msg.type === 'disconnected') {
|
||||||
|
wasDisconnectedBySSH.current = true;
|
||||||
|
terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (!wasDisconnectedBySSH.current) {
|
||||||
|
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||||
|
|
||||||
|
terminal.options = {
|
||||||
|
cursorBlink: false,
|
||||||
|
cursorStyle: 'bar',
|
||||||
|
scrollback: 10000,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
||||||
|
theme: {background: '#09090b', foreground: '#f7f7f7'},
|
||||||
|
allowTransparency: true,
|
||||||
|
convertEol: true,
|
||||||
|
windowsMode: false,
|
||||||
|
macOptionIsMeta: false,
|
||||||
|
macOptionClickForcesSelection: false,
|
||||||
|
rightClickSelectsWord: false,
|
||||||
|
fastScrollModifier: 'alt',
|
||||||
|
fastScrollSensitivity: 5,
|
||||||
|
allowProposedApi: true,
|
||||||
|
disableStdin: true,
|
||||||
|
cursorInactiveStyle: "bar",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
const clipboardAddon = new ClipboardAddon();
|
||||||
|
const unicode11Addon = new Unicode11Addon();
|
||||||
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.loadAddon(clipboardAddon);
|
||||||
|
terminal.loadAddon(unicode11Addon);
|
||||||
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
terminal.open(xtermRef.current);
|
||||||
|
|
||||||
|
const textarea = xtermRef.current.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.readOnly = true;
|
||||||
|
textarea.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.focus = () => {};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
|
resizeTimeout.current = setTimeout(() => {
|
||||||
|
if (!isVisibleRef.current) return;
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(xtermRef.current);
|
||||||
|
|
||||||
|
const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve();
|
||||||
|
readyFonts.then(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
|
setVisible(true);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const cols = terminal.cols;
|
||||||
|
const rows = terminal.rows;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
setupWebSocketListeners(ws, cols, rows);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
|
if (pingIntervalRef.current) {
|
||||||
|
clearInterval(pingIntervalRef.current);
|
||||||
|
pingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
webSocketRef.current?.close();
|
||||||
|
};
|
||||||
|
}, [xtermRef, terminal, hostConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible && fitAddonRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [isVisible, terminal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fitAddonRef.current) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
hardRefresh();
|
||||||
|
}, 0);
|
||||||
|
}, [isVisible, terminal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={xtermRef}
|
||||||
|
className="h-full w-full m-1"
|
||||||
|
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.innerHTML = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
|
||||||
|
|
||||||
|
/* Load NerdFonts locally */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono Nerd Font';
|
||||||
|
src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono Nerd Font';
|
||||||
|
src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono Nerd Font';
|
||||||
|
src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(180,180,180,0.7);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(120,120,120,0.9);
|
||||||
|
}
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(180,180,180,0.7) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
font-feature-settings: "liga" 1, "calt" 1;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
|
||||||
|
font-variant-ligatures: contextual;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen .xterm-char {
|
||||||
|
font-feature-settings: "liga" 1, "calt" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen .xterm-char[data-char-code^="\uE000"] {
|
||||||
|
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
183
src/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx
Normal file
183
src/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import React, {useState, useCallback, useEffect} from "react";
|
||||||
|
import Keyboard from "react-simple-keyboard";
|
||||||
|
import "react-simple-keyboard/build/css/index.css";
|
||||||
|
import "./kb-dark-theme.css";
|
||||||
|
|
||||||
|
interface TerminalKeyboardProps {
|
||||||
|
onSendInput: (input: string) => void;
|
||||||
|
onLayoutChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalKeyboard({onSendInput, onLayoutChange}: TerminalKeyboardProps) {
|
||||||
|
const [layoutName, setLayoutName] = useState("default");
|
||||||
|
const [isCtrl, setIsCtrl] = useState(false);
|
||||||
|
const [isAlt, setIsAlt] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onLayoutChange) {
|
||||||
|
const timeoutId = setTimeout(() => onLayoutChange(), 100);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [layoutName, onLayoutChange]);
|
||||||
|
|
||||||
|
const onKeyPress = useCallback((button: string) => {
|
||||||
|
if (button === "{shift}") {
|
||||||
|
setLayoutName("shift");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button === "{unshift}") {
|
||||||
|
setLayoutName("default");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button === "{more}") {
|
||||||
|
setLayoutName("more");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button === "{less}") {
|
||||||
|
setLayoutName("default");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button === "{hide}") {
|
||||||
|
setLayoutName("hide");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button === "{unhide}") {
|
||||||
|
setLayoutName("default");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button === "{ctrl}") {
|
||||||
|
setIsCtrl(prev => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button === "{alt}") {
|
||||||
|
setIsAlt(prev => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = button;
|
||||||
|
|
||||||
|
const specialKeyMap: { [key: string]: string } = {
|
||||||
|
"{esc}": "\x1b", "{enter}": "\r", "{tab}": "\t", "{backspace}": "\x7f",
|
||||||
|
"{arrowUp}": "\x1b[A", "{arrowDown}": "\x1b[B", "{arrowRight}": "\x1b[C", "{arrowLeft}": "\x1b[D",
|
||||||
|
"{home}": "\x1b[H", "{end}": "\x1b[F", "{pgUp}": "\x1b[5~", "{pgDn}": "\x1b[6~",
|
||||||
|
"F1": "\x1bOP", "F2": "\x1bOQ", "F3": "\x1bOR", "F4": "\x1bOS",
|
||||||
|
"F5": "\x1b[15~", "F6": "\x1b[17~", "F7": "\x1b[18~", "F8": "\x1b[19~",
|
||||||
|
"F9": "\x1b[20~", "F10": "\x1b[21~", "F11": "\x1b[23~", "F12": "\x1b[24~",
|
||||||
|
"{space}": " "
|
||||||
|
};
|
||||||
|
|
||||||
|
if (specialKeyMap[input]) {
|
||||||
|
input = specialKeyMap[input];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCtrl) {
|
||||||
|
if (input.length === 1) {
|
||||||
|
const charCode = input.toUpperCase().charCodeAt(0);
|
||||||
|
if (charCode >= 64 && charCode <= 95) {
|
||||||
|
input = String.fromCharCode(charCode - 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAlt) {
|
||||||
|
input = `\x1b${input}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(20);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Vibration failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSendInput(input);
|
||||||
|
}, [onSendInput, isCtrl, isAlt]);
|
||||||
|
|
||||||
|
const buttonTheme = [
|
||||||
|
{
|
||||||
|
class: "hg-space-big",
|
||||||
|
buttons: "{space}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
class: "hg-space-medium",
|
||||||
|
buttons: "{enter} {backspace}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
class: "hg-space-small",
|
||||||
|
buttons: "{hide} {unhide} {less} {more}",
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isCtrl) {
|
||||||
|
buttonTheme.push({class: "key-active", buttons: "{ctrl}"});
|
||||||
|
}
|
||||||
|
if (isAlt) {
|
||||||
|
buttonTheme.push({class: "key-active", buttons: "{alt}"});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="z-10">
|
||||||
|
<Keyboard
|
||||||
|
layout={{
|
||||||
|
default: [
|
||||||
|
"{esc} {tab} {ctrl} {alt} {arrowLeft} {arrowRight} {arrowUp} {arrowDown}",
|
||||||
|
"q w e r t y u i o p",
|
||||||
|
"a s d f g h j k l",
|
||||||
|
"{shift} z x c v b n m {backspace}",
|
||||||
|
"{hide} {more} {space} {enter}",
|
||||||
|
],
|
||||||
|
shift: [
|
||||||
|
"{esc} {tab} {ctrl} {alt} {arrowLeft} {arrowRight} {arrowUp} {arrowDown}",
|
||||||
|
"Q W E R T Y U I O P",
|
||||||
|
"A S D F G H J K L",
|
||||||
|
"{unshift} Z X C V B N M {backspace}",
|
||||||
|
"{hide} {more} {space} {enter}",
|
||||||
|
],
|
||||||
|
more: [
|
||||||
|
"{esc} {tab} {ctrl} {alt} {end} {home} {pgUp} {pgDn}",
|
||||||
|
"1 2 3 4 5 6 7 8 9 0",
|
||||||
|
"! @ # $ % ^ & * ( ) _ +",
|
||||||
|
"[ ] { } | \\ ; : ' \" , . / < >",
|
||||||
|
"F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"{arrowLeft} {arrowRight} {arrowUp} {arrowDown} {backspace}",
|
||||||
|
"{hide} {less} {space} {enter}",
|
||||||
|
],
|
||||||
|
hide: [
|
||||||
|
"{unhide}"
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
layoutName={layoutName}
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
display={{
|
||||||
|
"{shift}": "up",
|
||||||
|
"{unshift}": "dn",
|
||||||
|
"{backspace}": "back",
|
||||||
|
"{more}": "more",
|
||||||
|
"{less}": "less",
|
||||||
|
"{space}": "space",
|
||||||
|
"{enter}": "enter",
|
||||||
|
"{arrowLeft}": "←",
|
||||||
|
"{arrowRight}": "→",
|
||||||
|
"{arrowUp}": "↑",
|
||||||
|
"{arrowDown}": "↓",
|
||||||
|
"{hide}": "hide",
|
||||||
|
"{unhide}": "unhide",
|
||||||
|
"{esc}": "esc",
|
||||||
|
"{tab}": "tab",
|
||||||
|
"{ctrl}": "ctrl",
|
||||||
|
"{alt}": "alt",
|
||||||
|
"{end}": "end",
|
||||||
|
"{home}": "home",
|
||||||
|
"{pgUp}": "pgUp",
|
||||||
|
"{pgDn}": "pgDn",
|
||||||
|
}}
|
||||||
|
theme={"hg-theme-default dark-theme"}
|
||||||
|
useTouchEvents={true}
|
||||||
|
disableButtonHold={true}
|
||||||
|
buttonTheme={buttonTheme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/ui/Mobile/Apps/Terminal/kb-dark-theme.css
Normal file
40
src/ui/Mobile/Apps/Terminal/kb-dark-theme.css
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
.simple-keyboard.dark-theme {
|
||||||
|
background-color: rgb(24, 24, 27);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard.dark-theme .hg-button {
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #bfbfbf;
|
||||||
|
border-bottom-color: rgb(122, 122, 122);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard.dark-theme .hg-button:active {
|
||||||
|
background: rgba(83, 83, 83, 0.5);
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root .simple-keyboard.dark-theme + .simple-keyboard-preview {
|
||||||
|
background: rgba(83, 83, 83, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-theme .hg-button.key-active {
|
||||||
|
background: rgba(126, 126, 126, 0.5);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-space-big {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-space-medium {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-space-small {
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
798
src/ui/Mobile/Homepage/HomepageAuth.tsx
Normal file
798
src/ui/Mobile/Homepage/HomepageAuth.tsx
Normal file
@@ -0,0 +1,798 @@
|
|||||||
|
import React, {useState, useEffect} from "react";
|
||||||
|
import {cn} from "@/lib/utils.ts";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
|
import {Label} from "@/components/ui/label.tsx";
|
||||||
|
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {LanguageSwitcher} from "@/components/LanguageSwitcher.tsx";
|
||||||
|
import {
|
||||||
|
registerUser,
|
||||||
|
loginUser,
|
||||||
|
getUserInfo,
|
||||||
|
getRegistrationAllowed,
|
||||||
|
getOIDCConfig,
|
||||||
|
getUserCount,
|
||||||
|
initiatePasswordReset,
|
||||||
|
verifyPasswordResetCode,
|
||||||
|
completePasswordReset,
|
||||||
|
getOIDCAuthorizeUrl,
|
||||||
|
verifyTOTPLogin,
|
||||||
|
setCookie
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
|
const parts = v.split('=');
|
||||||
|
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||||
|
setLoggedIn: (loggedIn: boolean) => void;
|
||||||
|
setIsAdmin: (isAdmin: boolean) => void;
|
||||||
|
setUsername: (username: string | null) => void;
|
||||||
|
setUserId: (userId: string | null) => void;
|
||||||
|
loggedIn: boolean;
|
||||||
|
authLoading: boolean;
|
||||||
|
dbError: string | null;
|
||||||
|
setDbError: (error: string | null) => void;
|
||||||
|
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomepageAuth({
|
||||||
|
className,
|
||||||
|
setLoggedIn,
|
||||||
|
setIsAdmin,
|
||||||
|
setUsername,
|
||||||
|
setUserId,
|
||||||
|
loggedIn,
|
||||||
|
authLoading,
|
||||||
|
dbError,
|
||||||
|
setDbError,
|
||||||
|
onAuthSuccess,
|
||||||
|
...props
|
||||||
|
}: HomepageAuthProps) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
|
||||||
|
const [localUsername, setLocalUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||||
|
const [firstUser, setFirstUser] = useState(false);
|
||||||
|
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||||
|
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||||
|
|
||||||
|
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
|
||||||
|
const [resetCode, setResetCode] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [tempToken, setTempToken] = useState("");
|
||||||
|
const [resetLoading, setResetLoading] = useState(false);
|
||||||
|
const [resetSuccess, setResetSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [totpRequired, setTotpRequired] = useState(false);
|
||||||
|
const [totpCode, setTotpCode] = useState("");
|
||||||
|
const [totpTempToken, setTotpTempToken] = useState("");
|
||||||
|
const [totpLoading, setTotpLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalLoggedIn(loggedIn);
|
||||||
|
}, [loggedIn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getRegistrationAllowed().then(res => {
|
||||||
|
setRegistrationAllowed(res.allowed);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOIDCConfig().then((response) => {
|
||||||
|
if (response) {
|
||||||
|
setOidcConfigured(true);
|
||||||
|
} else {
|
||||||
|
setOidcConfigured(false);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
setOidcConfigured(false);
|
||||||
|
} else {
|
||||||
|
setOidcConfigured(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserCount().then(res => {
|
||||||
|
if (res.count === 0) {
|
||||||
|
setFirstUser(true);
|
||||||
|
setTab("signup");
|
||||||
|
} else {
|
||||||
|
setFirstUser(false);
|
||||||
|
}
|
||||||
|
setDbError(null);
|
||||||
|
}).catch(() => {
|
||||||
|
setDbError(t('errors.databaseConnection'));
|
||||||
|
});
|
||||||
|
}, [setDbError]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (!localUsername.trim()) {
|
||||||
|
setError(t('errors.requiredField'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res, meRes;
|
||||||
|
if (tab === "login") {
|
||||||
|
res = await loginUser(localUsername, password);
|
||||||
|
} else {
|
||||||
|
if (password !== signupConfirmPassword) {
|
||||||
|
setError(t('errors.passwordMismatch'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError(t('errors.minLength', {min: 6}));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerUser(localUsername, password);
|
||||||
|
res = await loginUser(localUsername, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.requires_totp) {
|
||||||
|
setTotpRequired(true);
|
||||||
|
setTotpTempToken(res.temp_token);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !res.token) {
|
||||||
|
throw new Error(t('errors.noTokenReceived'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie("jwt", res.token);
|
||||||
|
[meRes] = await Promise.all([
|
||||||
|
getUserInfo(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
setUserId(meRes.userId || null);
|
||||||
|
setDbError(null);
|
||||||
|
onAuthSuccess({
|
||||||
|
isAdmin: !!meRes.is_admin,
|
||||||
|
username: meRes.username || null,
|
||||||
|
userId: meRes.userId || null
|
||||||
|
});
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
if (tab === "signup") {
|
||||||
|
setSignupConfirmPassword("");
|
||||||
|
}
|
||||||
|
setTotpRequired(false);
|
||||||
|
setTotpCode("");
|
||||||
|
setTotpTempToken("");
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || err?.message || t('errors.unknownError'));
|
||||||
|
setInternalLoggedIn(false);
|
||||||
|
setLoggedIn(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setUserId(null);
|
||||||
|
setCookie("jwt", "", -1);
|
||||||
|
if (err?.response?.data?.error?.includes("Database")) {
|
||||||
|
setDbError(t('errors.databaseConnection'));
|
||||||
|
} else {
|
||||||
|
setDbError(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInitiatePasswordReset() {
|
||||||
|
setError(null);
|
||||||
|
setResetLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await initiatePasswordReset(localUsername);
|
||||||
|
setResetStep("verify");
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset'));
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyResetCode() {
|
||||||
|
setError(null);
|
||||||
|
setResetLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
||||||
|
setTempToken(response.tempToken);
|
||||||
|
setResetStep("newPassword");
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || t('errors.failedVerifyCode'));
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCompletePasswordReset() {
|
||||||
|
setError(null);
|
||||||
|
setResetLoading(true);
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError(t('errors.passwordMismatch'));
|
||||||
|
setResetLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError(t('errors.minLength', {min: 6}));
|
||||||
|
setResetLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completePasswordReset(localUsername, tempToken, newPassword);
|
||||||
|
|
||||||
|
setResetStep("initiate");
|
||||||
|
setResetCode("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setTempToken("");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setResetSuccess(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || t('errors.failedCompleteReset'));
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPasswordState() {
|
||||||
|
setResetStep("initiate");
|
||||||
|
setResetCode("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setTempToken("");
|
||||||
|
setError(null);
|
||||||
|
setResetSuccess(false);
|
||||||
|
setSignupConfirmPassword("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFormFields() {
|
||||||
|
setPassword("");
|
||||||
|
setSignupConfirmPassword("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTOTPVerification() {
|
||||||
|
if (totpCode.length !== 6) {
|
||||||
|
setError(t('auth.enterCode'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setTotpLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
||||||
|
|
||||||
|
if (!res || !res.token) {
|
||||||
|
throw new Error(t('errors.noTokenReceived'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie("jwt", res.token);
|
||||||
|
const meRes = await getUserInfo();
|
||||||
|
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
setUserId(meRes.userId || null);
|
||||||
|
setDbError(null);
|
||||||
|
onAuthSuccess({
|
||||||
|
isAdmin: !!meRes.is_admin,
|
||||||
|
username: meRes.username || null,
|
||||||
|
userId: meRes.userId || null
|
||||||
|
});
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
setTotpRequired(false);
|
||||||
|
setTotpCode("");
|
||||||
|
setTotpTempToken("");
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || err?.message || t('errors.invalidTotpCode'));
|
||||||
|
} finally {
|
||||||
|
setTotpLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOIDCLogin() {
|
||||||
|
setError(null);
|
||||||
|
setOidcLoading(true);
|
||||||
|
try {
|
||||||
|
const authResponse = await getOIDCAuthorizeUrl();
|
||||||
|
const {auth_url: authUrl} = authResponse;
|
||||||
|
|
||||||
|
if (!authUrl || authUrl === 'undefined') {
|
||||||
|
throw new Error(t('errors.invalidAuthUrl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace(authUrl);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error || err?.message || t('errors.failedOidcLogin'));
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const success = urlParams.get('success');
|
||||||
|
const token = urlParams.get('token');
|
||||||
|
const error = urlParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(`${t('errors.oidcAuthFailed')}: ${error}`);
|
||||||
|
setOidcLoading(false);
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success && token) {
|
||||||
|
setOidcLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setCookie("jwt", token);
|
||||||
|
getUserInfo()
|
||||||
|
.then(meRes => {
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
setUserId(meRes.id || null);
|
||||||
|
setDbError(null);
|
||||||
|
onAuthSuccess({
|
||||||
|
isAdmin: !!meRes.is_admin,
|
||||||
|
username: meRes.username || null,
|
||||||
|
userId: meRes.id || null
|
||||||
|
});
|
||||||
|
setInternalLoggedIn(true);
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setError(t('errors.failedUserInfo'));
|
||||||
|
setInternalLoggedIn(false);
|
||||||
|
setLoggedIn(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setUserId(null);
|
||||||
|
setCookie("jwt", "", -1);
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setOidcLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const Spinner = (
|
||||||
|
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full max-w-md flex flex-col bg-[#18181b] ${className || ''}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{dbError && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{dbError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{firstUser && !dbError && !internalLoggedIn && (
|
||||||
|
<Alert variant="default" className="mb-4">
|
||||||
|
<AlertTitle>{t('auth.firstUser')}</AlertTitle>
|
||||||
|
<AlertDescription className="inline">
|
||||||
|
{t('auth.firstUserMessage')}{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||||
|
>
|
||||||
|
GitHub Issue
|
||||||
|
</a>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!registrationAllowed && !internalLoggedIn && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>{t('auth.registerTitle')}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t('messages.registrationDisabled')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{totpRequired && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h2 className="text-xl font-bold mb-1">{t('auth.twoFactorAuth')}</h2>
|
||||||
|
<p className="text-muted-foreground">{t('auth.enterCode')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="totp-code">{t('auth.verifyCode')}</Label>
|
||||||
|
<Input
|
||||||
|
id="totp-code"
|
||||||
|
type="text"
|
||||||
|
placeholder="000000"
|
||||||
|
maxLength={6}
|
||||||
|
value={totpCode}
|
||||||
|
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
disabled={totpLoading}
|
||||||
|
className="text-center text-2xl tracking-widest font-mono"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
{t('auth.backupCode')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={totpLoading || totpCode.length < 6}
|
||||||
|
onClick={handleTOTPVerification}
|
||||||
|
>
|
||||||
|
{totpLoading ? Spinner : t('auth.verifyCode')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={totpLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setTotpRequired(false);
|
||||||
|
setTotpCode("");
|
||||||
|
setTotpTempToken("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "login"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("login");
|
||||||
|
if (tab === "reset") resetPasswordState();
|
||||||
|
if (tab === "signup") clearFormFields();
|
||||||
|
}}
|
||||||
|
aria-selected={tab === "login"}
|
||||||
|
disabled={loading || firstUser}
|
||||||
|
>
|
||||||
|
{t('common.login')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "signup"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("signup");
|
||||||
|
if (tab === "reset") resetPasswordState();
|
||||||
|
if (tab === "login") clearFormFields();
|
||||||
|
}}
|
||||||
|
aria-selected={tab === "signup"}
|
||||||
|
disabled={loading || !registrationAllowed}
|
||||||
|
>
|
||||||
|
{t('common.register')}
|
||||||
|
</button>
|
||||||
|
{oidcConfigured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "external"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("external");
|
||||||
|
if (tab === "reset") resetPasswordState();
|
||||||
|
if (tab === "login" || tab === "signup") clearFormFields();
|
||||||
|
}}
|
||||||
|
aria-selected={tab === "external"}
|
||||||
|
disabled={oidcLoading}
|
||||||
|
>
|
||||||
|
{t('auth.external')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h2 className="text-xl font-bold mb-1">
|
||||||
|
{tab === "login" ? t('auth.loginTitle') :
|
||||||
|
tab === "signup" ? t('auth.registerTitle') :
|
||||||
|
tab === "external" ? t('auth.loginWithExternal') :
|
||||||
|
t('auth.forgotPassword')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "external" || tab === "reset" ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{tab === "external" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>{t('auth.loginWithExternalDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
|
disabled={oidcLoading}
|
||||||
|
onClick={handleOIDCLogin}
|
||||||
|
>
|
||||||
|
{oidcLoading ? Spinner : t('auth.loginWithExternal')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === "reset" && (
|
||||||
|
<>
|
||||||
|
{resetStep === "initiate" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>{t('auth.resetCodeDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="reset-username">{t('common.username')}</Label>
|
||||||
|
<Input
|
||||||
|
id="reset-username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={localUsername}
|
||||||
|
onChange={e => setLocalUsername(e.target.value)}
|
||||||
|
disabled={resetLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || !localUsername.trim()}
|
||||||
|
onClick={handleInitiatePasswordReset}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : t('auth.sendResetCode')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetStep === "verify" && (
|
||||||
|
<>o
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>{t('auth.enterResetCode')} <strong>{localUsername}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="reset-code">{t('auth.resetCode')}</Label>
|
||||||
|
<Input
|
||||||
|
id="reset-code"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
className="h-11 text-base text-center text-lg tracking-widest"
|
||||||
|
value={resetCode}
|
||||||
|
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
disabled={resetLoading}
|
||||||
|
placeholder="000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || resetCode.length !== 6}
|
||||||
|
onClick={handleVerifyResetCode}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : t('auth.verifyCodeButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setResetStep("initiate");
|
||||||
|
setResetCode("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetSuccess && (
|
||||||
|
<>
|
||||||
|
<Alert className="mb-4">
|
||||||
|
<AlertTitle>{t('auth.passwordResetSuccess')}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t('auth.passwordResetSuccessDesc')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
onClick={() => {
|
||||||
|
setTab("login");
|
||||||
|
resetPasswordState();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth.goToLogin')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetStep === "newPassword" && !resetSuccess && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>{t('auth.enterNewPassword')} <strong>{localUsername}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="new-password">{t('auth.newPassword')}</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
disabled={resetLoading}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={resetLoading}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||||
|
onClick={handleCompletePasswordReset}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : t('auth.resetPasswordButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setResetStep("verify");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="username">{t('common.username')}</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={localUsername}
|
||||||
|
onChange={e => setLocalUsername(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="password">{t('common.password')}</Label>
|
||||||
|
<Input id="password" type="password" required className="h-11 text-base"
|
||||||
|
value={password} onChange={e => setPassword(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}/>
|
||||||
|
</div>
|
||||||
|
{tab === "signup" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
|
||||||
|
<Input id="signup-confirm-password" type="password" required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={signupConfirmPassword}
|
||||||
|
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
|
disabled={loading || internalLoggedIn}>
|
||||||
|
{loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))}
|
||||||
|
</Button>
|
||||||
|
{tab === "login" && (
|
||||||
|
<Button type="button" variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={loading || internalLoggedIn}
|
||||||
|
onClick={() => {
|
||||||
|
setTab("reset");
|
||||||
|
resetPasswordState();
|
||||||
|
clearFormFields();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth.resetPasswordButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-[#303032]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-muted-foreground">{t('common.language')}</Label>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mt-4">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/ui/Mobile/MobileApp.tsx
Normal file
206
src/ui/Mobile/MobileApp.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import React, {useRef, FC, useState, useEffect} from "react";
|
||||||
|
import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
|
||||||
|
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
|
||||||
|
import {BottomNavbar} from "@/ui/Mobile/Navigation/BottomNavbar.tsx";
|
||||||
|
import {LeftSidebar} from "@/ui/Mobile/Navigation/LeftSidebar.tsx";
|
||||||
|
import {TabProvider, useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
||||||
|
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||||
|
import {HomepageAuth} from "@/ui/Mobile/Homepage/HomepageAuth.tsx";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
|
const parts = v.split('=');
|
||||||
|
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppContent: FC = () => {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {tabs, currentTab, getTab} = useTabs();
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
||||||
|
const [ready, setReady] = React.useState(true);
|
||||||
|
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = () => {
|
||||||
|
const jwt = getCookie("jwt");
|
||||||
|
if (jwt) {
|
||||||
|
setAuthLoading(true);
|
||||||
|
getUserInfo()
|
||||||
|
.then((meRes) => {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
})
|
||||||
|
.finally(() => setAuthLoading(false));
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setUsername(null);
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth()
|
||||||
|
|
||||||
|
const handleStorageChange = () => checkAuth()
|
||||||
|
window.addEventListener('storage', handleStorageChange)
|
||||||
|
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fitCurrentTerminal()
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setIsAdmin(authData.isAdmin)
|
||||||
|
setUsername(authData.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitCurrentTerminal = () => {
|
||||||
|
const tab = getTab(currentTab as number);
|
||||||
|
if (tab && tab.terminalRef?.current?.fit) {
|
||||||
|
tab.terminalRef.current.fit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (tabs.length > 0) {
|
||||||
|
setReady(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fitCurrentTerminal();
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentTab]);
|
||||||
|
|
||||||
|
const closeSidebar = () => setIsSidebarOpen(false);
|
||||||
|
|
||||||
|
const handleKeyboardLayoutChange = () => {
|
||||||
|
fitCurrentTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboardInput(input: string) {
|
||||||
|
const currentTerminalTab = getTab(currentTab as number);
|
||||||
|
if (currentTerminalTab && currentTerminalTab.terminalRef?.current?.sendInput) {
|
||||||
|
currentTerminalTab.terminalRef.current.sendInput(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center bg-[#09090b]">
|
||||||
|
<p className="text-white">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center bg-[#18181b] p-4">
|
||||||
|
<HomepageAuth
|
||||||
|
setLoggedIn={setIsAuthenticated}
|
||||||
|
setIsAdmin={setIsAdmin}
|
||||||
|
setUsername={setUsername}
|
||||||
|
setUserId={(id) => {
|
||||||
|
}}
|
||||||
|
loggedIn={isAuthenticated}
|
||||||
|
authLoading={authLoading}
|
||||||
|
dbError={null}
|
||||||
|
setDbError={(err) => {
|
||||||
|
}}
|
||||||
|
onAuthSuccess={handleAuthSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex flex-col bg-[#09090b] overflow-y-hidden overflow-x-hidden relative">
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className="absolute inset-0 mb-2"
|
||||||
|
style={{
|
||||||
|
visibility: tab.id === currentTab ? 'visible' : 'hidden',
|
||||||
|
opacity: ready ? 1 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Terminal
|
||||||
|
ref={tab.terminalRef}
|
||||||
|
hostConfig={tab.hostConfig}
|
||||||
|
isVisible={tab.id === currentTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tabs.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-white gap-3 px-4 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{t('mobile.selectHostToStart')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-300 max-w-xs">
|
||||||
|
{t('mobile.limitedSupportMessage')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{currentTab &&
|
||||||
|
<div className="mb-1 z-10">
|
||||||
|
<TerminalKeyboard onSendInput={handleKeyboardInput} onLayoutChange={handleKeyboardLayoutChange}/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<BottomNavbar
|
||||||
|
onSidebarOpenClick={() => setIsSidebarOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/30 backdrop-blur-sm z-10"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute top-0 left-0 h-full z-20 pointer-events-none">
|
||||||
|
<div onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}} className="pointer-events-auto">
|
||||||
|
<LeftSidebar
|
||||||
|
isSidebarOpen={isSidebarOpen}
|
||||||
|
setIsSidebarOpen={setIsSidebarOpen}
|
||||||
|
onHostConnect={closeSidebar}
|
||||||
|
disabled={!isAuthenticated || authLoading}
|
||||||
|
username={username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileApp: FC = () => {
|
||||||
|
return (
|
||||||
|
<TabProvider>
|
||||||
|
<AppContent/>
|
||||||
|
</TabProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/ui/Mobile/Navigation/BottomNavbar.tsx
Normal file
48
src/ui/Mobile/Navigation/BottomNavbar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Menu, X, Terminal as TerminalIcon} from "lucide-react";
|
||||||
|
import {useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
||||||
|
import {cn} from "@/lib/utils.ts";
|
||||||
|
|
||||||
|
interface MenuProps {
|
||||||
|
onSidebarOpenClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomNavbar({onSidebarOpenClick}: MenuProps) {
|
||||||
|
const {tabs, currentTab, setCurrentTab, removeTab} = useTabs();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[50px] bg-[#18181B] items-center p-1">
|
||||||
|
<div className="flex gap-2 !mb-0.5">
|
||||||
|
<Button className="w-[40px] h-[40px] flex-shrink-0" variant="outline" onClick={onSidebarOpenClick}>
|
||||||
|
<Menu/>
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 overflow-x-auto whitespace-nowrap thin-scrollbar">
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-10 rounded-r-none !px-3 border-1 border-[#303032]",
|
||||||
|
tab.id === currentTab && '!bg-[#09090b] !text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentTab(tab.id)}
|
||||||
|
>
|
||||||
|
<TerminalIcon className="mr-1 h-4 w-4"/>
|
||||||
|
{tab.title}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 rounded-l-none !px-2 border-1 border-[#303032]"
|
||||||
|
onClick={() => removeTab(tab.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/ui/Mobile/Navigation/Hosts/FolderCard.tsx
Normal file
81
src/ui/Mobile/Navigation/Hosts/FolderCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, {useState} from "react";
|
||||||
|
import {CardTitle} from "@/components/ui/card.tsx";
|
||||||
|
import {ChevronDown, Folder} from "lucide-react";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import {Host} from "@/ui/Mobile/Navigation/Hosts/Host.tsx";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderCardProps {
|
||||||
|
folderName: string;
|
||||||
|
hosts: SSHHost[];
|
||||||
|
onHostConnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderCard({folderName, hosts, onHostConnect}: FolderCardProps): React.ReactElement {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||||
|
style={{padding: '0', margin: '0'}}>
|
||||||
|
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||||
|
<div className="flex gap-2 pr-10">
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
<Folder size={16} strokeWidth={3}/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
>
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="flex flex-col p-2 gap-y-3">
|
||||||
|
{hosts.map((host, index) => (
|
||||||
|
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||||
|
<Host host={host} onHostConnect={onHostConnect}/>
|
||||||
|
|
||||||
|
{index < hosts.length - 1 && (
|
||||||
|
<div className="relative -mx-2">
|
||||||
|
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
src/ui/Mobile/Navigation/Hosts/Host.tsx
Normal file
107
src/ui/Mobile/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
|
import {Server, Terminal} from "lucide-react";
|
||||||
|
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||||
|
import {useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostProps {
|
||||||
|
host: SSHHost;
|
||||||
|
onHostConnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Host({host, onHostConnect}: HostProps): React.ReactElement {
|
||||||
|
const {addTab} = useTabs();
|
||||||
|
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded');
|
||||||
|
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||||
|
const hasTags = tags.length > 0;
|
||||||
|
|
||||||
|
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId: number | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getServerStatusById(host.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setServerStatus('offline');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStatus();
|
||||||
|
|
||||||
|
intervalId = window.setInterval(fetchStatus, 10000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [host.id]);
|
||||||
|
|
||||||
|
const handleTerminalClick = () => {
|
||||||
|
addTab({type: 'terminal', title, hostConfig: host});
|
||||||
|
onHostConnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||||
|
<StatusIndicator/>
|
||||||
|
</Status>
|
||||||
|
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||||
|
{host.name || host.ip}
|
||||||
|
</p>
|
||||||
|
<ButtonGroup className="flex-shrink-0">
|
||||||
|
{host.enableTerminal && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 border-1 w-[60px] border-[#303032]"
|
||||||
|
onClick={handleTerminalClick}
|
||||||
|
>
|
||||||
|
<Terminal/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
{hasTags && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
{tags.map((tag: string) => (
|
||||||
|
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
|
||||||
|
<p className="text-sm">{tag}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
src/ui/Mobile/Navigation/LeftSidebar.tsx
Normal file
227
src/ui/Mobile/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||||
|
SidebarProvider
|
||||||
|
} from "@/components/ui/sidebar.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {ChevronUp, Menu, User2} from "lucide-react";
|
||||||
|
import React, {useState, useEffect, useMemo, useCallback} from "react";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import {FolderCard} from "@/ui/Mobile/Navigation/Hosts/FolderCard.tsx";
|
||||||
|
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
|
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeftSidebarProps {
|
||||||
|
isSidebarOpen: boolean;
|
||||||
|
setIsSidebarOpen: (type: boolean) => void;
|
||||||
|
onHostConnect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftSidebar({isSidebarOpen, setIsSidebarOpen, onHostConnect, disabled, username}: LeftSidebarProps) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
|
const [hostsLoading, setHostsLoading] = useState(false);
|
||||||
|
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||||
|
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
|
||||||
|
const fetchHosts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const newHosts = await getSSHHosts();
|
||||||
|
const prevHosts = prevHostsRef.current;
|
||||||
|
|
||||||
|
if (JSON.stringify(newHosts) !== JSON.stringify(prevHosts)) {
|
||||||
|
setHosts(newHosts);
|
||||||
|
prevHostsRef.current = newHosts;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setHostsError(t('leftSidebar.failedToLoadHosts'));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHosts();
|
||||||
|
const interval = setInterval(fetchHosts, 300000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchHosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleHostsChanged = () => {
|
||||||
|
fetchHosts();
|
||||||
|
};
|
||||||
|
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
|
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
|
}, [fetchHosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const filteredHosts = useMemo(() => {
|
||||||
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
|
return hosts.filter(h => {
|
||||||
|
const searchableText = [
|
||||||
|
h.name || '',
|
||||||
|
h.username,
|
||||||
|
h.ip,
|
||||||
|
h.folder || '',
|
||||||
|
...(h.tags || []),
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return searchableText.includes(q);
|
||||||
|
});
|
||||||
|
}, [hosts, debouncedSearch]);
|
||||||
|
|
||||||
|
const hostsByFolder = useMemo(() => {
|
||||||
|
const map: Record<string, SSHHost[]> = {};
|
||||||
|
filteredHosts.forEach(h => {
|
||||||
|
const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder');
|
||||||
|
if (!map[folder]) map[folder] = [];
|
||||||
|
map[folder].push(h);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [filteredHosts, t]);
|
||||||
|
|
||||||
|
const sortedFolders = useMemo(() => {
|
||||||
|
const folders = Object.keys(hostsByFolder);
|
||||||
|
folders.sort((a, b) => {
|
||||||
|
if (a === t('leftSidebar.noFolder')) return 1;
|
||||||
|
if (b === t('leftSidebar.noFolder')) return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
return folders;
|
||||||
|
}, [hostsByFolder, t]);
|
||||||
|
|
||||||
|
const getSortedHosts = useCallback((arr: SSHHost[]) => {
|
||||||
|
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
return [...pinned, ...rest];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<SidebarProvider open={isSidebarOpen}>
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||||
|
Termix
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
className="w-[28px] h-[28px] absolute right-5"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
</SidebarHeader>
|
||||||
|
<Separator/>
|
||||||
|
<SidebarContent className="px-2 py-2">
|
||||||
|
<div className="!bg-[#222225] rounded-lg">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder={t('placeholders.searchHostsAny')}
|
||||||
|
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hostsError && (
|
||||||
|
<div className="px-1">
|
||||||
|
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||||
|
{t('leftSidebar.failedToLoadHosts')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hostsLoading && (
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
{t('hosts.loadingHosts')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedFolders.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={`folder-${folder}`}
|
||||||
|
folderName={folder}
|
||||||
|
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||||
|
onHostConnect={onHostConnect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
<Separator className="mt-1"/>
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
className="data-[state=open]:opacity-90 w-full"
|
||||||
|
style={{width: '100%'}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<User2/> {username ? username : t('common.logout')}
|
||||||
|
<ChevronUp className="ml-auto"/>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
|
onClick={handleLogout}>
|
||||||
|
|
||||||
|
<span>{t('common.logout')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
</SidebarProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/ui/Mobile/Navigation/Tabs/TabContext.tsx
Normal file
100
src/ui/Mobile/Navigation/Tabs/TabContext.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: number;
|
||||||
|
type: 'terminal';
|
||||||
|
title: string;
|
||||||
|
hostConfig?: any;
|
||||||
|
terminalRef?: React.RefObject<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabContextType {
|
||||||
|
tabs: Tab[];
|
||||||
|
currentTab: number | null;
|
||||||
|
addTab: (tab: Omit<Tab, 'id'>) => number;
|
||||||
|
removeTab: (tabId: number) => void;
|
||||||
|
setCurrentTab: (tabId: number) => void;
|
||||||
|
getTab: (tabId: number) => Tab | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function useTabs() {
|
||||||
|
const context = useContext(TabContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTabs must be used within a TabProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabProvider({children}: TabProviderProps) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||||
|
const [currentTab, setCurrentTab] = useState<number | null>(null);
|
||||||
|
const nextTabId = useRef(1);
|
||||||
|
|
||||||
|
function computeUniqueTitle(desiredTitle: string | undefined): string {
|
||||||
|
const baseTitle = (desiredTitle || 'Terminal').trim();
|
||||||
|
const existingTitles = tabs.map(t => t.title);
|
||||||
|
if (!existingTitles.includes(baseTitle)) {
|
||||||
|
return baseTitle;
|
||||||
|
}
|
||||||
|
let i = 2;
|
||||||
|
while (existingTitles.includes(`${baseTitle} (${i})`)) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${baseTitle} (${i})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTab = (tabData: Omit<Tab, 'id'>): number => {
|
||||||
|
const id = nextTabId.current++;
|
||||||
|
const newTab: Tab = {
|
||||||
|
...tabData,
|
||||||
|
id,
|
||||||
|
title: computeUniqueTitle(tabData.title),
|
||||||
|
terminalRef: React.createRef<any>()
|
||||||
|
};
|
||||||
|
setTabs(prev => [...prev, newTab]);
|
||||||
|
setCurrentTab(id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTab = (tabId: number) => {
|
||||||
|
const tab = tabs.find(t => t.id === tabId);
|
||||||
|
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
|
||||||
|
tab.terminalRef.current.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabs(prev => {
|
||||||
|
const newTabs = prev.filter(tab => tab.id !== tabId);
|
||||||
|
if (currentTab === tabId) {
|
||||||
|
setCurrentTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
|
||||||
|
}
|
||||||
|
return newTabs;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTab = (tabId: number) => {
|
||||||
|
return tabs.find(tab => tab.id === tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: TabContextType = {
|
||||||
|
tabs,
|
||||||
|
currentTab,
|
||||||
|
addTab,
|
||||||
|
removeTab,
|
||||||
|
setCurrentTab,
|
||||||
|
getTab,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</TabContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,11 +12,12 @@ interface SSHHostData {
|
|||||||
folder?: string;
|
folder?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
authType: 'password' | 'key';
|
authType: 'password' | 'key' | 'credential';
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: File | null;
|
key?: File | null;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
|
credentialId?: number | null;
|
||||||
enableTerminal?: boolean;
|
enableTerminal?: boolean;
|
||||||
enableTunnel?: boolean;
|
enableTunnel?: boolean;
|
||||||
enableFileManager?: boolean;
|
enableFileManager?: boolean;
|
||||||
@@ -38,6 +39,7 @@ interface SSHHost {
|
|||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
|
credentialId?: number;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableFileManager: boolean;
|
enableFileManager: boolean;
|
||||||
@@ -140,6 +142,7 @@ interface AuthResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
|
totp_enabled: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
@@ -158,15 +161,28 @@ interface OIDCAuthorize {
|
|||||||
// UTILITY FUNCTIONS
|
// UTILITY FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function setCookie(name: string, value: string, days = 7): void {
|
export function setCookie(name: string, value: string, days = 7): void {
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
|
||||||
|
if (isElectron) {
|
||||||
|
localStorage.setItem(name, value);
|
||||||
|
} else {
|
||||||
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCookie(name: string): string | undefined {
|
function getCookie(name: string): string | undefined {
|
||||||
const value = `; ${document.cookie}`;
|
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||||
const parts = value.split(`; ${name}=`);
|
if (isElectron) {
|
||||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
const token = localStorage.getItem(name) || undefined;
|
||||||
|
return token;
|
||||||
|
} else {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
const token = parts.length === 2 ? parts.pop()?.split(';').shift() : undefined;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createApiInstance(baseURL: string): AxiosInstance {
|
function createApiInstance(baseURL: string): AxiosInstance {
|
||||||
@@ -180,6 +196,8 @@ function createApiInstance(baseURL: string): AxiosInstance {
|
|||||||
const token = getCookie('jwt');
|
const token = getCookie('jwt');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
console.log('No token found, Authorization header not set');
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
@@ -201,34 +219,64 @@ function createApiInstance(baseURL: string): AxiosInstance {
|
|||||||
// API INSTANCES
|
// API INSTANCES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development' &&
|
const isDev = process.env.NODE_ENV === 'development' &&
|
||||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||||
|
|
||||||
|
let apiHost = import.meta.env.VITE_API_HOST || 'localhost';
|
||||||
|
let apiPort = 8081;
|
||||||
|
|
||||||
|
if (isElectron) {
|
||||||
|
apiPort = 8081;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiUrl(path: string, defaultPort: number): string {
|
||||||
|
if (isElectron) {
|
||||||
|
return `http://127.0.0.1:${defaultPort}${path}`;
|
||||||
|
} else if (isDev) {
|
||||||
|
return `http://${apiHost}:${defaultPort}${path}`;
|
||||||
|
} else {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-port backend architecture (original design)
|
||||||
// SSH Host Management API (port 8081)
|
// SSH Host Management API (port 8081)
|
||||||
export const sshHostApi = createApiInstance(
|
export let sshHostApi = createApiInstance(
|
||||||
isDev ? 'http://localhost:8081/ssh' : '/ssh'
|
getApiUrl('/ssh', 8081)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tunnel Management API (port 8083)
|
// Tunnel Management API (port 8083)
|
||||||
export const tunnelApi = createApiInstance(
|
export let tunnelApi = createApiInstance(
|
||||||
isDev ? 'http://localhost:8083/ssh' : '/ssh'
|
getApiUrl('/ssh', 8083)
|
||||||
);
|
);
|
||||||
|
|
||||||
// File Manager Operations API (port 8084) - SSH file operations
|
// File Manager Operations API (port 8084) - SSH file operations
|
||||||
export const fileManagerApi = createApiInstance(
|
export let fileManagerApi = createApiInstance(
|
||||||
isDev ? 'http://localhost:8084/ssh/file_manager' : '/ssh/file_manager'
|
getApiUrl('/ssh/file_manager', 8084)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Server Statistics API (port 8085)
|
// Server Statistics API (port 8085)
|
||||||
export const statsApi = createApiInstance(
|
export let statsApi = createApiInstance(
|
||||||
isDev ? 'http://localhost:8085' : ''
|
getApiUrl('', 8085)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Authentication API (port 8081) - includes users, alerts, version, releases
|
// Authentication API (port 8081) - includes users, alerts, version, releases
|
||||||
export const authApi = createApiInstance(
|
export let authApi = createApiInstance(
|
||||||
isDev ? 'http://localhost:8081' : ''
|
getApiUrl('', 8081)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Function to update API instances with new port (for Electron)
|
||||||
|
function updateApiPorts(port: number) {
|
||||||
|
apiPort = port;
|
||||||
|
sshHostApi = createApiInstance(`http://127.0.0.1:${port}/ssh`);
|
||||||
|
tunnelApi = createApiInstance(`http://127.0.0.1:${port}/ssh`);
|
||||||
|
fileManagerApi = createApiInstance(`http://127.0.0.1:${port}/ssh/file_manager`);
|
||||||
|
statsApi = createApiInstance(`http://127.0.0.1:${port}`);
|
||||||
|
authApi = createApiInstance(`http://127.0.0.1:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ERROR HANDLING
|
// ERROR HANDLING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -293,10 +341,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
tags: hostData.tags || [],
|
tags: hostData.tags || [],
|
||||||
pin: hostData.pin || false,
|
pin: hostData.pin || false,
|
||||||
authMethod: hostData.authType,
|
authMethod: hostData.authType,
|
||||||
|
authType: hostData.authType,
|
||||||
password: hostData.authType === 'password' ? hostData.password : '',
|
password: hostData.authType === 'password' ? hostData.password : '',
|
||||||
key: hostData.authType === 'key' ? hostData.key : null,
|
key: hostData.authType === 'key' ? hostData.key : null,
|
||||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||||
|
credentialId: hostData.authType === 'credential' ? hostData.credentialId : null,
|
||||||
enableTerminal: hostData.enableTerminal !== false,
|
enableTerminal: hostData.enableTerminal !== false,
|
||||||
enableTunnel: hostData.enableTunnel !== false,
|
enableTunnel: hostData.enableTunnel !== false,
|
||||||
enableFileManager: hostData.enableFileManager !== false,
|
enableFileManager: hostData.enableFileManager !== false,
|
||||||
@@ -344,10 +394,12 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
|||||||
tags: hostData.tags || [],
|
tags: hostData.tags || [],
|
||||||
pin: hostData.pin || false,
|
pin: hostData.pin || false,
|
||||||
authMethod: hostData.authType,
|
authMethod: hostData.authType,
|
||||||
|
authType: hostData.authType,
|
||||||
password: hostData.authType === 'password' ? hostData.password : '',
|
password: hostData.authType === 'password' ? hostData.password : '',
|
||||||
key: hostData.authType === 'key' ? hostData.key : null,
|
key: hostData.authType === 'key' ? hostData.key : null,
|
||||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||||
|
credentialId: hostData.authType === 'credential' ? hostData.credentialId : null,
|
||||||
enableTerminal: hostData.enableTerminal !== false,
|
enableTerminal: hostData.enableTerminal !== false,
|
||||||
enableTunnel: hostData.enableTunnel !== false,
|
enableTunnel: hostData.enableTunnel !== false,
|
||||||
enableFileManager: hostData.enableFileManager !== false,
|
enableFileManager: hostData.enableFileManager !== false,
|
||||||
@@ -772,8 +824,9 @@ export async function getOIDCConfig(): Promise<any> {
|
|||||||
try {
|
try {
|
||||||
const response = await authApi.get('/users/oidc-config');
|
const response = await authApi.get('/users/oidc-config');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
handleApiError(error, 'fetch OIDC config');
|
console.warn('Failed to fetch OIDC config:', error.response?.data?.error || error.message);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,7 +998,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string)
|
|||||||
|
|
||||||
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
|
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
|
||||||
try {
|
try {
|
||||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||||
const response = await apiInstance.get(`/alerts/user/${userId}`);
|
const response = await apiInstance.get(`/alerts/user/${userId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -956,7 +1009,7 @@ export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }>
|
|||||||
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Use the general API instance since alerts endpoint is at root level
|
// Use the general API instance since alerts endpoint is at root level
|
||||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||||
const response = await apiInstance.post('/alerts/dismiss', { userId, alertId });
|
const response = await apiInstance.post('/alerts/dismiss', { userId, alertId });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -970,9 +1023,7 @@ export async function dismissAlert(userId: string, alertId: string): Promise<any
|
|||||||
|
|
||||||
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Use the general API instance since releases endpoint is at root level
|
const response = await authApi.get(`/releases/rss?per_page=${perPage}`);
|
||||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
|
||||||
const response = await apiInstance.get(`/releases/rss?per_page=${perPage}`);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'fetch releases RSS');
|
handleApiError(error, 'fetch releases RSS');
|
||||||
@@ -981,9 +1032,7 @@ export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
|||||||
|
|
||||||
export async function getVersionInfo(): Promise<any> {
|
export async function getVersionInfo(): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Use the general API instance since version endpoint is at root level
|
const response = await authApi.get('/version');
|
||||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
|
||||||
const response = await apiInstance.get('/version/');
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'fetch version info');
|
handleApiError(error, 'fetch version info');
|
||||||
@@ -1001,4 +1050,105 @@ export async function getDatabaseHealth(): Promise<any> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'check database health');
|
handleApiError(error, 'check database health');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH CREDENTIALS MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getCredentials(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/credentials');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCredentialDetails(credentialId: number): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get(`/credentials/${credentialId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch credential details');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCredential(credentialData: any): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/credentials', credentialData);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'create credential');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCredential(credentialId: number, credentialData: any): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.put(`/credentials/${credentialId}`, credentialData);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'update credential');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCredential(credentialId: number): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete(`/credentials/${credentialId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'delete credential');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCredentialHosts(credentialId: number): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get(`/credentials/${credentialId}/hosts`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch credential hosts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCredentialFolders(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/credentials/folders');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch credential folders');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCredentialToHost(credentialId: number, hostId: number): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post(`/credentials/${credentialId}/apply-to-host/${hostId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'apply credential to host');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH FOLDER MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getFoldersWithStats(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/ssh/db/folders/with-stats');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch folders with statistics');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFolder(oldName: string, newName: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.put('/ssh/db/folders/rename', {
|
||||||
|
oldName,
|
||||||
|
newName
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'rename folder');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,4 +11,5 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
base: './', // 使用相对路径,适配 Electron
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user