diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..5c9cb19c --- /dev/null +++ b/.claude/settings.local.json @@ -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": [] + } +} \ No newline at end of file diff --git a/.env b/.env index c1e19f61..0e92ae62 100644 --- a/.env +++ b/.env @@ -1 +1,3 @@ -VERSION=1.5.0 \ No newline at end of file +VERSION=1.6.0 +VITE_API_HOST=localhost +CREDENTIAL_ENCRYPTION_KEY=98fbfabe84b125db7cbbb5168eb584aaecc2f3779a2aaa955c57bdd305071a84 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..aa45c083 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..6051e2ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.gitignore b/.gitignore index d0adddb5..d643c797 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.sln *.sw? /db/ +/release/ diff --git a/README-CN.md b/README-CN.md new file mode 100644 index 00000000..54611470 --- /dev/null +++ b/README-CN.md @@ -0,0 +1,100 @@ +# Repo Stats + +

+ English English | + 中文 中文 +

+ +![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars) +![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) +![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) +Discord +#### Top Technologies +[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#) +[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#) +[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#) +[![Vite Badge](https://img.shields.io/badge/-Vite-646CFF?style=flat-square&labelColor=black&logo=vite&logoColor=646CFF)](#) +[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#) +[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#) +[![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#) +[![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#) + +
+

+ + Termix Banner +

+ +如果你愿意,可以在这里支持这个项目!\ +[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) + +# Overview + +

+ + Termix Banner +

+ +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 + +

+ Termix Demo 1 + Termix Demo 2 +

+ +

+ Termix Demo 3 + Termix Demo 4 + Termix Demo 5 +

+ +

+ +

+ +# License +根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。 + diff --git a/README.md b/README.md index 64fcca64..e14e5f0c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ # Repo Stats +

+ English English | + 中文 中文 +

+ + ![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) diff --git a/electron-builder.json b/electron-builder.json new file mode 100644 index 00000000..83ea470e --- /dev/null +++ b/electron-builder.json @@ -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"] + } + ] + } +} \ No newline at end of file diff --git a/electron/main-simple.cjs b/electron/main-simple.cjs new file mode 100644 index 00000000..063367cf --- /dev/null +++ b/electron/main-simple.cjs @@ -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); +}); \ No newline at end of file diff --git a/electron/main.cjs b/electron/main.cjs new file mode 100644 index 00000000..a16ffa47 --- /dev/null +++ b/electron/main.cjs @@ -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); +}); \ No newline at end of file diff --git a/electron/preload-simple.cjs b/electron/preload-simple.cjs new file mode 100644 index 00000000..b5182248 --- /dev/null +++ b/electron/preload-simple.cjs @@ -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); \ No newline at end of file diff --git a/electron/preload.cjs b/electron/preload.cjs new file mode 100644 index 00000000..222c6e9a --- /dev/null +++ b/electron/preload.cjs @@ -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; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5f0e369d..e6ebd921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "react-hook-form": "^7.60.0", "react-i18next": "^15.7.3", "react-resizable-panels": "^3.0.3", + "react-simple-keyboard": "^3.8.120", "react-xtermjs": "^1.0.10", "sonner": "^2.0.7", "speakeasy": "^2.0.0", @@ -96,6 +97,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "prettier": "^3.6.2", "ts-node": "^10.9.2", "tw-animate-css": "^1.3.5", "typescript": "~5.9.2", @@ -7610,6 +7612,22 @@ "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": { "version": "2.0.7", "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" } }, + "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": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/package.json b/package.json index 81530bff..0c4ff32c 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "react-hook-form": "^7.60.0", "react-i18next": "^15.7.3", "react-resizable-panels": "^3.0.3", + "react-simple-keyboard": "^3.8.120", "react-xtermjs": "^1.0.10", "sonner": "^2.0.7", "speakeasy": "^2.0.0", diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 00000000..756b57e1 Binary files /dev/null and b/public/icon.png differ diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index d1d55c02..ce060c7f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -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": { "title": "SSH Tools", "closeTools": "Close SSH Tools", @@ -32,6 +158,7 @@ "loading": "Loading", "required": "Required", "optional": "Optional", + "clear": "Clear", "toggleSidebar": "Toggle Sidebar", "sidebar": "Sidebar", "home": "Home", @@ -72,6 +199,7 @@ "register": "Register", "username": "Username", "password": "Password", + "version" : "Version", "confirmPassword": "Confirm Password", "back": "Back", "email": "Email", @@ -120,6 +248,7 @@ "nav": { "home": "Home", "hosts": "Hosts", + "credentials": "Credentials", "terminal": "Terminal", "tunnels": "Tunnels", "fileManager": "File Manager", @@ -131,10 +260,12 @@ "closeTab": "Close Tab", "sshManager": "SSH Manager", "hostManager": "Host Manager", - "cannotSplitTab": "Cannot split this tab" + "cannotSplitTab": "Cannot split this tab", + "tabNavigation": "Tab Navigation" }, "admin": { "title": "Admin Settings", + "oidc": "OIDC", "users": "Users", "userManagement": "User Management", "makeAdmin": "Make Admin", @@ -208,7 +339,7 @@ "downloadSample": "Download Sample", "formatGuide": "Format Guide", "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", "jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts", "noHostsInJson": "No hosts found in JSON file", @@ -276,6 +407,10 @@ "authentication": "Authentication", "password": "Password", "key": "Key", + "credential": "Credential", + "selectCredential": "Select Credential", + "selectCredentialPlaceholder": "Choose a credential...", + "credentialRequired": "Credential is required when using credential authentication", "sshPrivateKey": "SSH Private Key", "keyPassword": "Key Password", "keyType": "Key Type", @@ -297,7 +432,14 @@ "terminal": "Terminal", "tunnel": "Tunnel", "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": { "title": "Terminal", @@ -665,6 +807,9 @@ "folder": "folder", "password": "password", "keyPassword": "key password", + "credentialName": "My SSH Server", + "description": "SSH credential description", + "searchCredentials": "Search credentials by name, username, or tags...", "sshConfig": "endpoint ssh configuration", "homePath": "/home", "clientId": "your-client-id", @@ -675,6 +820,7 @@ "userIdField": "sub", "usernameField": "name", "scopes": "openid email profile", + "userinfoUrl": "https://your-provider.com/application/o/userinfo/", "enterUsername": "Enter username to make admin", "searchHosts": "Search hosts by name, username, IP, folder, tags...", "enterPassword": "Enter your password", @@ -810,5 +956,9 @@ "invalidVerificationCode": "Invalid verification code", "failedToDisableTotp": "Failed to disable TOTP", "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." } } \ No newline at end of file diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index c8f299dc..d2bb6cdb 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -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": { "title": "SSH 工具", "closeTools": "关闭 SSH 工具", @@ -32,6 +158,7 @@ "loading": "加载中", "required": "必填", "optional": "可选", + "clear": "清除", "toggleSidebar": "切换侧边栏", "sidebar": "侧边栏", "home": "首页", @@ -120,6 +247,7 @@ "nav": { "home": "首页", "hosts": "主机", + "credentials": "凭据", "terminal": "终端", "tunnels": "隧道", "fileManager": "文件管理器", @@ -131,10 +259,12 @@ "closeTab": "关闭标签页", "sshManager": "SSH 管理器", "hostManager": "主机管理器", - "cannotSplitTab": "无法分割此标签页" + "cannotSplitTab": "无法分割此标签页", + "tabNavigation": "标签导航" }, "admin": { "title": "管理员设置", + "oidc": "OIDC", "users": "用户", "userManagement": "用户管理", "makeAdmin": "设为管理员", @@ -223,6 +353,7 @@ "port": "端口", "name": "名称", "username": "用户名", + "hostName": "主机名", "folder": "文件夹", "tags": "标签", "passwordRequired": "使用密码认证时需要密码", @@ -296,6 +427,10 @@ "authentication": "认证方式", "password": "密码", "key": "密钥", + "credential": "凭证", + "selectCredential": "选择凭证", + "selectCredentialPlaceholder": "选择一个凭证...", + "credentialRequired": "使用凭证认证时需要选择凭证", "sshPrivateKey": "SSH 私钥", "keyPassword": "密钥密码", "keyType": "密钥类型", @@ -334,7 +469,14 @@ "general": "常规", "terminal": "终端", "tunnel": "隧道", - "fileManager": "文件管理器" + "fileManager": "文件管理器", + "confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。", + "removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除", + "failedToRemoveFromFolder": "从文件夹中移除主机失败", + "folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"", + "failedToRenameFolder": "重命名文件夹失败", + "movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"", + "failedToMoveToFolder": "移动主机到文件夹失败" }, "terminal": { "title": "终端", @@ -613,7 +755,7 @@ "firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建", "external": "外部", "loginWithExternal": "使用外部提供商登录", - "loginWithExternalDesc": "使用您配置的外部身份提供商登录", + "loginWithExternalDesc": "使用您配置的外部身份提供者登录", "resetPasswordButton": "重置密码", "sendResetCode": "发送重置代码", "resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。", @@ -701,6 +843,9 @@ "hostname": "主机名", "folder": "文件夹", "password": "密码", + "credentialName": "我的SSH服务器", + "description": "SSH凭据描述", + "searchCredentials": "按名称、用户名或标签搜索凭据...", "keyPassword": "密钥密码", "sshConfig": "端点 SSH 配置", "homePath": "/home", @@ -712,6 +857,7 @@ "userIdField": "sub", "usernameField": "name", "scopes": "openid email profile", + "userinfoUrl": "https://your-provider.com/application/o/userinfo/", "enterUsername": "输入用户名以设为管理员", "searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...", "enterPassword": "输入您的密码", @@ -851,5 +997,9 @@ "invalidVerificationCode": "无效的验证码", "failedToDisableTotp": "禁用 TOTP 失败", "failedToGenerateBackupCodes": "生成备用码失败" + }, + "mobile": { + "selectHostToStart": "选择一个主机以开始您的终端会话", + "limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。" } } \ No newline at end of file diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index a59f1ffd..40466490 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -3,9 +3,12 @@ import bodyParser from 'body-parser'; import userRoutes from './routes/users.js'; import sshRoutes from './routes/ssh.js'; import alertRoutes from './routes/alerts.js'; +import credentialsRoutes from './routes/credentials.js'; import chalk from 'chalk'; import cors from 'cors'; import fetch from 'node-fetch'; +import fs from 'fs'; +import path from 'path'; import 'dotenv/config'; const app = express(); @@ -143,10 +146,21 @@ app.get('/health', (req, res) => { }); app.get('/version', async (req, res) => { - const localVersion = process.env.VERSION; + let localVersion = process.env.VERSION; 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 { @@ -166,6 +180,7 @@ app.get('/version', async (req, res) => { const response = { status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update', + localVersion: localVersion, version: remoteVersion, latest_release: { tag_name: releaseData.data.tag_name, @@ -235,9 +250,11 @@ app.get('/releases/rss', async (req, res) => { } }); + app.use('/users', userRoutes); app.use('/ssh', sshRoutes); app.use('/alerts', alertRoutes); +app.use('/credentials', credentialsRoutes); app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { logger.error('Unhandled error:', err); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 2dd60b79..4e08b3db 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -4,6 +4,7 @@ import * as schema from './schema.js'; import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; +import { MigrationManager } from '../migrations/migrator.js'; const dbIconSymbol = '🗄️'; const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); @@ -432,6 +433,9 @@ const migrateSchema = () => { addColumnIfNotExists('ssh_data', 'default_path', 'TEXT'); addColumnIfNotExists('ssh_data', 'created_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_pinned', 'host_id', 'INTEGER NOT NULL'); @@ -440,15 +444,27 @@ const migrateSchema = () => { logger.success('Schema migration completed'); }; -migrateSchema(); +const initializeDatabase = async () => { + migrateSchema(); -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(); + // Run new migration system + const migrationManager = new MigrationManager(sqlite); + await migrationManager.runMigrations(); + + 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}); \ No newline at end of file diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 81300eea..6fc5cb54 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -17,7 +17,7 @@ export const users = sqliteTable('users', { identifier_path: text('identifier_path'), name_path: text('name_path'), scopes: text().default("openid email profile"), - + totp_secret: text('totp_secret'), totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false), totp_backup_codes: text('totp_backup_codes'), @@ -39,10 +39,13 @@ export const sshData = sqliteTable('ssh_data', { tags: text('tags'), pin: integer('pin', {mode: 'boolean'}).notNull().default(false), authType: text('auth_type').notNull(), + // Legacy credential fields - kept for backward compatibility password: text('password'), key: text('key', {length: 8192}), keyPassword: text('key_password'), keyType: text('key_type'), + // New credential management + credentialId: integer('credential_id').references(() => sshCredentials.id), enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true), enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true), tunnelConnections: text('tunnel_connections'), @@ -84,4 +87,32 @@ export const dismissedAlerts = sqliteTable('dismissed_alerts', { userId: text('user_id').notNull().references(() => users.id), alertId: text('alert_id').notNull(), 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`), }); \ No newline at end of file diff --git a/src/backend/database/migrations/001-add-credentials-tables.ts b/src/backend/database/migrations/001-add-credentials-tables.ts new file mode 100644 index 00000000..a163856e --- /dev/null +++ b/src/backend/database/migrations/001-add-credentials-tables.ts @@ -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'); +}; \ No newline at end of file diff --git a/src/backend/database/migrations/migrator.ts b/src/backend/database/migrations/migrator.ts new file mode 100644 index 00000000..a37566ee --- /dev/null +++ b/src/backend/database/migrations/migrator.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }; \ No newline at end of file diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts new file mode 100644 index 00000000..90160e3e --- /dev/null +++ b/src/backend/database/routes/credentials.ts @@ -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; \ No newline at end of file diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index e9ad54b8..5941b4ab 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -144,6 +144,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque username, password, authMethod, + authType, + credentialId, key, keyPassword, 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'}); } + const effectiveAuthType = authType || authMethod; const sshDataObj: any = { userId: userId, name, @@ -168,7 +171,8 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque ip, port, username, - authType: authMethod, + authType: effectiveAuthType, + credentialId: credentialId || null, pin: !!pin ? 1 : 0, enableTerminal: !!enableTerminal ? 1 : 0, enableTunnel: !!enableTunnel ? 1 : 0, @@ -177,12 +181,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque defaultPath: defaultPath || null, }; - if (authMethod === 'password') { + if (effectiveAuthType === 'password') { sshDataObj.password = password; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; - } else if (authMethod === 'key') { + } else if (effectiveAuthType === 'key') { sshDataObj.key = key; sshDataObj.keyPassword = keyPassword; sshDataObj.keyType = keyType; @@ -232,6 +236,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re username, password, authMethod, + authType, + credentialId, key, keyPassword, 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'}); } + const effectiveAuthType = authType || authMethod; const sshDataObj: any = { name, folder, @@ -256,7 +263,8 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re ip, port, username, - authType: authMethod, + authType: effectiveAuthType, + credentialId: credentialId || null, pin: !!pin ? 1 : 0, enableTerminal: !!enableTerminal ? 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, }; - if (authMethod === 'password') { - sshDataObj.password = password; + if (effectiveAuthType === 'password') { + if (password) { + sshDataObj.password = password; + } sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; - } else if (authMethod === 'key') { - sshDataObj.key = key; - sshDataObj.keyPassword = keyPassword; - sshDataObj.keyType = keyType; + } else if (effectiveAuthType === 'key') { + if (key) { + sshDataObj.key = key; + } + if (keyPassword !== undefined) { + sshDataObj.keyPassword = keyPassword; + } + if (keyType) { + sshDataObj.keyType = keyType; + } 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; + }> = {}; + + 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) // DELETE /ssh/host/:id 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) // POST /ssh/bulk-import router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 11dc46d0..2c2e9100 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -274,7 +274,7 @@ router.get('/oidc-config', async (req, res) => { try { const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get(); if (!row) { - return res.status(404).json({error: 'OIDC not configured'}); + return res.json(null); } res.json(JSON.parse((row as any).value)); } 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(fileManagerPinned).where(eq(fileManagerPinned.userId, targetUserId)); await db.delete(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, targetUserId)); - + await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId)); - + await db.delete(sshData).where(eq(sshData.userId, targetUserId)); - // Note: All user-related data has been deleted above // The tables config_editor_* and shared_hosts don't exist in the current schema } catch (cleanupError) { diff --git a/src/backend/services/credentials.ts b/src/backend/services/credentials.ts new file mode 100644 index 00000000..2b3b79ae --- /dev/null +++ b/src/backend/services/credentials.ts @@ -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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + try { + const result = await db + .select({folder: sshCredentials.folder}) + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)); + + const folderCounts: Record = {}; + 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(); \ No newline at end of file diff --git a/src/backend/services/encryption.ts b/src/backend/services/encryption.ts new file mode 100644 index 00000000..38bcaf66 --- /dev/null +++ b/src/backend/services/encryption.ts @@ -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 }; \ No newline at end of file diff --git a/src/backend/services/ssh-host.ts b/src/backend/services/ssh-host.ts new file mode 100644 index 00000000..95a13c1a --- /dev/null +++ b/src/backend/services/ssh-host.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); \ No newline at end of file diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 8823fd56..3d853b40 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -4,18 +4,10 @@ import fetch from 'node-fetch'; import net from 'net'; import cors from 'cors'; import {Client, type ConnectConfig} from 'ssh2'; +import {sshHostService} from '../services/ssh-host.js'; +import type {SSHHostWithCredentials} from '../services/ssh-host.js'; -type HostRecord = { - id: number; - ip: string; - port: number; - username?: string; - authType?: 'password' | 'key' | string; - password?: string | null; - key?: string | null; - keyPassword?: string | null; - keyType?: string | null; -}; +// Removed HostRecord - using SSHHostWithCredentials from ssh-host service instead type HostStatus = 'online' | 'offline'; @@ -69,7 +61,7 @@ const logger = { const hostStatuses: Map = new Map(); -async function fetchAllHosts(): Promise { +async function fetchAllHosts(): Promise { const url = 'http://localhost:8081/ssh/db/host/internal'; try { const resp = await fetch(url, { @@ -79,30 +71,55 @@ async function fetchAllHosts(): Promise { throw new Error(`DB service error: ${resp.status} ${resp.statusText}`); } const data = await resp.json(); - const hosts: HostRecord[] = (Array.isArray(data) ? data : []).map((h: any) => ({ - id: Number(h.id), - ip: String(h.ip), - port: Number(h.port) || 22, - username: h.username, - authType: h.authType, - password: h.password ?? null, - key: h.key ?? null, - keyPassword: h.keyPassword ?? null, - keyType: h.keyType ?? null, - })).filter(h => !!h.id && !!h.ip && !!h.port); - return hosts; + const rawHosts = Array.isArray(data) ? data : []; + + // Resolve credentials for each host using the same logic as main SSH connections + const hostsWithCredentials: SSHHostWithCredentials[] = []; + for (const rawHost of rawHosts) { + try { + // Use the ssh-host service to properly resolve credentials + const host = await sshHostService.getHostWithCredentials(rawHost.userId, rawHost.id); + if (host) { + hostsWithCredentials.push(host); + } + } 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) { logger.error('Failed to fetch hosts from database service', err); return []; } } -async function fetchHostById(id: number): Promise { - const all = await fetchAllHosts(); - return all.find(h => h.id === id); +async function fetchHostById(id: number): Promise { + try { + // 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 = { host: host.ip, port: host.port || 22, @@ -111,37 +128,41 @@ function buildSshConfig(host: HostRecord): ConnectConfig { algorithms: {} } as ConnectConfig; + // Use the same authentication logic as main SSH connections if (host.authType === 'password') { - (base as any).password = host.password || ''; - } else if (host.authType === 'key') { - 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}`); - } - } + if (!host.password) { + throw new Error(`No password available 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; } -async function withSshConnection(host: HostRecord, fn: (client: Client) => Promise): Promise { +async function withSshConnection(host: SSHHostWithCredentials, fn: (client: Client) => Promise): Promise { return new Promise((resolve, reject) => { const client = new Client(); let settled = false; @@ -225,7 +246,7 @@ function kibToGiB(kib: number): number { 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 }; memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null }; disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null }; diff --git a/src/components/CredentialSelector.tsx b/src/components/CredentialSelector.tsx new file mode 100644 index 00000000..d41361fe --- /dev/null +++ b/src/components/CredentialSelector.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const buttonRef = useRef(null); + const dropdownRef = useRef(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 ( + + {t('hosts.selectCredential')} + +
+ + + {dropdownOpen && ( +
+
+ setSearchQuery(e.target.value)} + className="h-8" + /> +
+ +
+ {loading ? ( +
+ {t('common.loading')} +
+ ) : filteredCredentials.length === 0 ? ( +
+ {searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')} +
+ ) : ( +
+ {value && ( + + )} + {filteredCredentials.map((credential) => ( + + ))} +
+ )} +
+ +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx index e3215e1b..154b1562 100644 --- a/src/components/ui/button-group.tsx +++ b/src/components/ui/button-group.tsx @@ -1,6 +1,6 @@ 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'; interface ButtonGroupProps { diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index a2df8dce..892d3d86 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -35,16 +35,19 @@ const buttonVariants = cva( } ) +export interface ButtonProps + extends React.ComponentProps<"button">, + VariantProps { + asChild?: boolean +} + function Button({ className, variant, size, asChild = false, ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { +}: ButtonProps) { const Comp = asChild ? Slot : "button" return ( @@ -56,4 +59,4 @@ function Button({ ) } -export { Button, buttonVariants } +export { Button, buttonVariants, type ButtonProps } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..92bdb930 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} \ No newline at end of file diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 1a6ea1e2..b031fbed 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -58,9 +58,9 @@ function SheetContent({ 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", 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" && - "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" && "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" && diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 291c11b2..350c8400 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -1,6 +1,6 @@ import * as React from "react" 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 { useIsMobile } from "@/hooks/use-mobile" diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index cd62aff2..33d2d2ad 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,5 +1,5 @@ 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 { theme = "system" } = useTheme() diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx new file mode 100644 index 00000000..b90a1ef4 --- /dev/null +++ b/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "../../lib/utils" + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +