v1.6.0 #221
@@ -119,7 +119,7 @@ reviews:
|
||||
- Identify and fix potential null/undefined access errors
|
||||
- Fix improper event handling and memory leaks
|
||||
- Resolve improper state management and data flow issues
|
||||
|
||||
|
||||
- path: "**/backend/**/*.{ts,js}"
|
||||
instructions: |
|
||||
Review backend code for Termix server management platform. Key considerations:
|
||||
@@ -167,7 +167,7 @@ reviews:
|
||||
- Implement proper health checks and status endpoints
|
||||
|
||||
Highlight any security vulnerabilities, performance issues, or architectural deviations.
|
||||
|
||||
|
||||
- path: "**/components/**/*.{ts,tsx}"
|
||||
instructions: |
|
||||
Review UI components for Termix server management platform. Key considerations:
|
||||
@@ -207,7 +207,7 @@ reviews:
|
||||
- Use proper tunnel status and management UI
|
||||
|
||||
Highlight any UI/UX issues, accessibility problems, or performance concerns.
|
||||
|
||||
|
||||
- path: "**/types/**/*.{ts,js}"
|
||||
instructions: |
|
||||
Review type definitions for Termix server management platform. Key considerations:
|
||||
@@ -237,7 +237,7 @@ reviews:
|
||||
- Use proper type assertions and casting
|
||||
|
||||
Highlight any type safety issues, missing types, or type inconsistencies.
|
||||
|
||||
|
||||
- path: "**/hooks/**/*.{ts,tsx}"
|
||||
instructions: |
|
||||
Review custom hooks for Termix server management platform. Key considerations:
|
||||
@@ -285,7 +285,7 @@ reviews:
|
||||
- Fix improper error handling in custom hooks
|
||||
|
||||
Highlight any hook design issues, performance problems, or reusability concerns.
|
||||
|
||||
|
||||
- path: "**/lib/**/*.{ts,js}"
|
||||
instructions: |
|
||||
Review utility libraries and helper functions for Termix server management platform. Key considerations:
|
||||
@@ -337,7 +337,7 @@ reviews:
|
||||
- Resolve improper configuration and environment variable handling
|
||||
|
||||
Highlight any utility design issues, performance problems, or security concerns.
|
||||
|
||||
|
||||
- path: "**/main-axios.ts"
|
||||
instructions: |
|
||||
Review main-axios.ts API client configuration for Termix server management platform. Key considerations:
|
||||
@@ -405,7 +405,7 @@ reviews:
|
||||
- Identify and fix potential security vulnerabilities in API handling
|
||||
|
||||
Highlight any API design issues, error handling problems, or security concerns.
|
||||
|
||||
|
||||
- path: "**/electron/**/*.{ts,js,cjs}"
|
||||
instructions: |
|
||||
Review Electron application code for Termix server management platform. Key considerations:
|
||||
@@ -443,7 +443,7 @@ reviews:
|
||||
- Identify and fix potential security vulnerabilities in Electron setup
|
||||
|
||||
Highlight any Electron-specific issues, security vulnerabilities, or performance problems.
|
||||
|
||||
|
||||
- path: "**/docker/**/*"
|
||||
instructions: |
|
||||
Review Docker configuration files for Termix server management platform. Key considerations:
|
||||
@@ -505,7 +505,7 @@ reviews:
|
||||
- Use proper visual aids and diagrams where appropriate
|
||||
|
||||
Highlight any documentation issues, inaccuracies, or missing information.
|
||||
|
||||
|
||||
- path: "**/index.css"
|
||||
instructions: |
|
||||
Review index.css styling configuration for Termix server management platform. Key considerations:
|
||||
|
||||
3
.env
3
.env
@@ -1,3 +1,2 @@
|
||||
VERSION=1.6.0
|
||||
VITE_API_HOST=localhost
|
||||
CREDENTIAL_ENCRYPTION_KEY=98fbfabe84b125db7cbbb5168eb584aaecc2f3779a2aaa955c57bdd305071a84
|
||||
VITE_API_HOST=localhost
|
||||
@@ -1,4 +1,4 @@
|
||||
# Contributing
|
||||
_# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -26,6 +26,7 @@ npm run dev
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
a
|
||||
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
||||
|
||||
## Contributing
|
||||
@@ -59,43 +60,48 @@ This will start the backend and the frontend Vite server. You can access Termix
|
||||
## Color Scheme
|
||||
|
||||
### Background Colors
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
|--------------|-------------|-------|-------------|
|
||||
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
|
||||
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
|
||||
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
||||
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
|
||||
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
|
||||
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards |
|
||||
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover |
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
|-------------------------------|-------------|-----------------------------|------------------------------------------|
|
||||
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
|
||||
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
|
||||
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
||||
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
|
||||
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
|
||||
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards |
|
||||
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover |
|
||||
|
||||
### Element-Specific Backgrounds
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
|--------------|-------------|-------|-------------|
|
||||
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
|
||||
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
|
||||
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
||||
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars |
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
|--------------------------|-------------|--------------------|-----------------------------------------------|
|
||||
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
|
||||
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
|
||||
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
||||
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars |
|
||||
|
||||
### Border Colors
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
|--------------|-------------|-------|-------------|
|
||||
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
||||
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
|
||||
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
|
||||
| `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
|
||||
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color |
|
||||
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards |
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
|------------------------------|-------------|-----------------|------------------------------------------|
|
||||
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
||||
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
|
||||
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
|
||||
| `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
|
||||
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color |
|
||||
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards |
|
||||
|
||||
### Interactive States
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
|--------------|-------------|-------|-------------|
|
||||
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
||||
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
|
||||
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
|
||||
| `--color-dark-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color |
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
|--------------------------|-------------|-------------------|-----------------------------------------------|
|
||||
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
||||
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
|
||||
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
|
||||
| `--color-dark-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color |
|
||||
|
||||
## Support
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo.
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
37
README-CN.md
37
README-CN.md
@@ -1,7 +1,7 @@
|
||||
# Repo Stats
|
||||
# 仓库统计
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> |
|
||||
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
|
||||
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
|
||||
</p>
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||

|
||||

|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||
#### Top Technologies
|
||||
|
||||
#### 核心技术
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
@@ -28,16 +30,18 @@
|
||||
如果你愿意,可以在这里支持这个项目!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Overview
|
||||
# 概览
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/LukeGus/Termix">
|
||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix 提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
|
||||
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
|
||||
提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
|
||||
|
||||
# 功能
|
||||
|
||||
# Features
|
||||
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
|
||||
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控
|
||||
- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等)
|
||||
@@ -47,14 +51,17 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
- **现代化界面** - 使用 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:
|
||||
@@ -70,13 +77,15 @@ services:
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
driver: local
|
||||
```
|
||||
|
||||
# Support
|
||||
如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。
|
||||
# 支持
|
||||
|
||||
# Show-off
|
||||
如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。
|
||||
|
||||
# 展示
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||
@@ -95,6 +104,6 @@ volumes:
|
||||
</video>
|
||||
</p>
|
||||
|
||||
# License
|
||||
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。
|
||||
# 许可证
|
||||
|
||||
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。
|
||||
34
README.md
34
README.md
@@ -1,4 +1,5 @@
|
||||
# Repo Stats
|
||||
|
||||
<p align="center">
|
||||
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English |
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
|
||||
@@ -9,7 +10,9 @@
|
||||

|
||||

|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||
|
||||
#### Top Technologies
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
@@ -35,24 +38,34 @@ If you would like, you can support the project here!\
|
||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
|
||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based
|
||||
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
|
||||
access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
|
||||
|
||||
# Features
|
||||
|
||||
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
|
||||
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
|
||||
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files)
|
||||
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (
|
||||
uploading, removing, renaming, deleting files)
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
|
||||
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
|
||||
- **Modern UI** - Clean mobile friendly (in progress) interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English and Chinese
|
||||
- **Improved Platform Support** - Now includes an installable Electron app (in progress) for desktop, with a dedicated mobile app also planned.
|
||||
- **Improved Platform Support** - Now includes an installable Electron app (in progress) for desktop, with a dedicated
|
||||
mobile app also planned.
|
||||
|
||||
# Planned Features
|
||||
See [Projects](https://github.com/users/LukeGus/projects/3). If you are looking to contribute, see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md),
|
||||
|
||||
See [Projects](https://github.com/users/LukeGus/projects/3). If you are looking to contribute,
|
||||
see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md),
|
||||
|
||||
# Installation
|
||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
||||
|
||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view
|
||||
a sample docker-compose file here:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
@@ -70,10 +83,16 @@ volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
```
|
||||
Pre-built binaries are now available for download, including a Windows installer/portable app and a Linux portable app (built with Electron). See [Docs](http://localhost:5174/install#pre-built-binaries) for details. A native iOS/Android app is planned.
|
||||
|
||||
Pre-built binaries are now available for download, including a Windows installer/portable app and a Linux portable app (
|
||||
built with Electron). See [Docs](http://localhost:5174/install#pre-built-binaries) for details. A native iOS/Android app
|
||||
is planned.
|
||||
|
||||
# Support
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo.
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
|
||||
# Show-off
|
||||
|
||||
@@ -95,4 +114,5 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
||||
</p>
|
||||
|
||||
# License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See LICENSE for more information.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
interface ConfirmationOptions {
|
||||
title: string;
|
||||
@@ -35,11 +35,10 @@ export function useConfirmation() {
|
||||
setOnConfirm(null);
|
||||
};
|
||||
|
||||
// For simple confirmations, we can use a toast with action
|
||||
const confirmWithToast = (message: string, callback: () => void, variant: 'default' | 'destructive' = 'default') => {
|
||||
const actionText = variant === 'destructive' ? 'Delete' : 'Confirm';
|
||||
const cancelText = 'Cancel';
|
||||
|
||||
|
||||
toast(message, {
|
||||
action: {
|
||||
label: actionText,
|
||||
@@ -47,9 +46,10 @@ export function useConfirmation() {
|
||||
},
|
||||
cancel: {
|
||||
label: cancelText,
|
||||
onClick: () => {}
|
||||
onClick: () => {
|
||||
}
|
||||
},
|
||||
duration: 10000, // Longer duration for confirmations
|
||||
duration: 10000,
|
||||
className: variant === 'destructive' ? 'border-red-500' : ''
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,17 +3,17 @@ import * as React from "react"
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
return !!isMobile
|
||||
}
|
||||
|
||||
@@ -1,47 +1,42 @@
|
||||
// i18n configuration for multi-language support
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import {initReactI18next} from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import translation files directly
|
||||
import enTranslation from '../locales/en/translation.json';
|
||||
import zhTranslation from '../locales/zh/translation.json';
|
||||
|
||||
// Initialize i18n
|
||||
i18n
|
||||
.use(LanguageDetector) // Detect user language
|
||||
.use(initReactI18next) // Pass i18n instance to react-i18next
|
||||
.init({
|
||||
supportedLngs: ['en', 'zh'], // Supported languages
|
||||
fallbackLng: 'en', // Fallback language
|
||||
debug: false,
|
||||
|
||||
// Detection options - disabled to always use English by default
|
||||
detection: {
|
||||
order: ['localStorage', 'cookie'], // Only check user's saved preference
|
||||
caches: ['localStorage', 'cookie'],
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
lookupCookie: 'i18nextLng',
|
||||
checkWhitelist: true,
|
||||
},
|
||||
|
||||
// Resources - load translations directly
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation
|
||||
}
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes values
|
||||
},
|
||||
|
||||
react: {
|
||||
useSuspense: false, // Disable suspense for SSR compatibility
|
||||
},
|
||||
});
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ['en', 'zh'],
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
|
||||
detection: {
|
||||
order: ['localStorage', 'cookie'],
|
||||
caches: ['localStorage', 'cookie'],
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
lookupCookie: 'i18nextLng',
|
||||
checkWhitelist: true,
|
||||
},
|
||||
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation
|
||||
}
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -180,24 +180,24 @@
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: #18181b;
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #434345;
|
||||
border-radius: 3px;
|
||||
background: #434345;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5d;
|
||||
background: #5a5a5d;
|
||||
}
|
||||
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #434345 #18181b;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #434345 #18181b;
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Frontend Logger - A comprehensive logging utility for the frontend
|
||||
* Enhanced with better formatting, readability, and request/response grouping
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
|
||||
|
||||
export interface LogContext {
|
||||
@@ -21,6 +16,7 @@ export interface LogContext {
|
||||
retryCount?: number;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -46,7 +42,7 @@ class FrontendLogger {
|
||||
const timestamp = this.getTimeStamp();
|
||||
const levelTag = this.getLevelTag(level);
|
||||
const serviceTag = this.getServiceTag();
|
||||
|
||||
|
||||
let contextStr = '';
|
||||
if (context && this.isDevelopment) {
|
||||
const contextParts = [];
|
||||
@@ -58,7 +54,7 @@ class FrontendLogger {
|
||||
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
|
||||
if (context.status) contextParts.push(`status:${context.status}`);
|
||||
if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
|
||||
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
contextStr = ` (${contextParts.join(', ')})`;
|
||||
}
|
||||
@@ -91,9 +87,9 @@ class FrontendLogger {
|
||||
|
||||
private log(level: LogLevel, message: string, context?: LogContext, error?: unknown): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
|
||||
const formattedMessage = this.formatMessage(level, message, context);
|
||||
|
||||
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(formattedMessage);
|
||||
@@ -136,60 +132,58 @@ class FrontendLogger {
|
||||
this.log('success', message, context);
|
||||
}
|
||||
|
||||
// Convenience methods for common operations
|
||||
api(message: string, context?: LogContext): void {
|
||||
this.info(`API: ${message}`, { ...context, operation: 'api' });
|
||||
this.info(`API: ${message}`, {...context, operation: 'api'});
|
||||
}
|
||||
|
||||
request(message: string, context?: LogContext): void {
|
||||
this.info(`REQUEST: ${message}`, { ...context, operation: 'request' });
|
||||
this.info(`REQUEST: ${message}`, {...context, operation: 'request'});
|
||||
}
|
||||
|
||||
response(message: string, context?: LogContext): void {
|
||||
this.info(`RESPONSE: ${message}`, { ...context, operation: 'response' });
|
||||
this.info(`RESPONSE: ${message}`, {...context, operation: 'response'});
|
||||
}
|
||||
|
||||
auth(message: string, context?: LogContext): void {
|
||||
this.info(`AUTH: ${message}`, { ...context, operation: 'auth' });
|
||||
this.info(`AUTH: ${message}`, {...context, operation: 'auth'});
|
||||
}
|
||||
|
||||
ssh(message: string, context?: LogContext): void {
|
||||
this.info(`SSH: ${message}`, { ...context, operation: 'ssh' });
|
||||
this.info(`SSH: ${message}`, {...context, operation: 'ssh'});
|
||||
}
|
||||
|
||||
tunnel(message: string, context?: LogContext): void {
|
||||
this.info(`TUNNEL: ${message}`, { ...context, operation: 'tunnel' });
|
||||
this.info(`TUNNEL: ${message}`, {...context, operation: 'tunnel'});
|
||||
}
|
||||
|
||||
file(message: string, context?: LogContext): void {
|
||||
this.info(`FILE: ${message}`, { ...context, operation: 'file' });
|
||||
this.info(`FILE: ${message}`, {...context, operation: 'file'});
|
||||
}
|
||||
|
||||
connection(message: string, context?: LogContext): void {
|
||||
this.info(`CONNECTION: ${message}`, { ...context, operation: 'connection' });
|
||||
this.info(`CONNECTION: ${message}`, {...context, operation: 'connection'});
|
||||
}
|
||||
|
||||
disconnect(message: string, context?: LogContext): void {
|
||||
this.info(`DISCONNECT: ${message}`, { ...context, operation: 'disconnect' });
|
||||
this.info(`DISCONNECT: ${message}`, {...context, operation: 'disconnect'});
|
||||
}
|
||||
|
||||
retry(message: string, context?: LogContext): void {
|
||||
this.warn(`RETRY: ${message}`, { ...context, operation: 'retry' });
|
||||
this.warn(`RETRY: ${message}`, {...context, operation: 'retry'});
|
||||
}
|
||||
|
||||
performance(message: string, context?: LogContext): void {
|
||||
this.info(`PERFORMANCE: ${message}`, { ...context, operation: 'performance' });
|
||||
this.info(`PERFORMANCE: ${message}`, {...context, operation: 'performance'});
|
||||
}
|
||||
|
||||
security(message: string, context?: LogContext): void {
|
||||
this.warn(`SECURITY: ${message}`, { ...context, operation: 'security' });
|
||||
this.warn(`SECURITY: ${message}`, {...context, operation: 'security'});
|
||||
}
|
||||
|
||||
// Enhanced API request/response logging methods
|
||||
requestStart(method: string, url: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
|
||||
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
|
||||
this.request(`→ Starting request to ${cleanUrl}`, {
|
||||
...context,
|
||||
@@ -203,7 +197,7 @@ class FrontendLogger {
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||
|
||||
|
||||
this.response(`← ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
@@ -218,7 +212,7 @@ class FrontendLogger {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
|
||||
|
||||
this.error(`← ${statusIcon} ${status} ${errorMessage}`, undefined, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
@@ -233,7 +227,7 @@ class FrontendLogger {
|
||||
networkError(method: string, url: string, errorMessage: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
|
||||
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
@@ -247,7 +241,7 @@ class FrontendLogger {
|
||||
authError(method: string, url: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
|
||||
this.security(`🔐 Authentication Required`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
@@ -260,7 +254,7 @@ class FrontendLogger {
|
||||
retryAttempt(method: string, url: string, attempt: number, maxAttempts: number, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
|
||||
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
@@ -269,25 +263,22 @@ class FrontendLogger {
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced logging for API operations
|
||||
apiOperation(operation: string, details: string, context?: LogContext): void {
|
||||
this.info(`🔧 ${operation}: ${details}`, { ...context, operation: 'api_operation' });
|
||||
this.info(`🔧 ${operation}: ${details}`, {...context, operation: 'api_operation'});
|
||||
}
|
||||
|
||||
// Log request summary for better debugging
|
||||
requestSummary(method: string, url: string, status: number, responseTime: number, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||
|
||||
console.log(`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
|
||||
'color: #666; font-style: italic; font-size: 0.9em;',
|
||||
|
||||
console.log(`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
|
||||
'color: #666; font-style: italic; font-size: 0.9em;',
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
// New helper methods for better formatting
|
||||
private getShortUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
@@ -316,10 +307,8 @@ class FrontendLogger {
|
||||
}
|
||||
|
||||
private sanitizeUrl(url: string): string {
|
||||
// Remove sensitive information from URLs for logging
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
// Remove query parameters that might contain sensitive data
|
||||
if (urlObj.searchParams.has('password') || urlObj.searchParams.has('token')) {
|
||||
urlObj.search = '';
|
||||
}
|
||||
@@ -330,7 +319,6 @@ class FrontendLogger {
|
||||
}
|
||||
}
|
||||
|
||||
// Service-specific loggers
|
||||
export const apiLogger = new FrontendLogger('API', '🌐', '#3b82f6');
|
||||
export const authLogger = new FrontendLogger('AUTH', '🔐', '#dc2626');
|
||||
export const sshLogger = new FrontendLogger('SSH', '🖥️', '#1e3a8a');
|
||||
@@ -339,5 +327,4 @@ export const fileLogger = new FrontendLogger('FILE', '📁', '#1e3a8a');
|
||||
export const statsLogger = new FrontendLogger('STATS', '📊', '#22c55e');
|
||||
export const systemLogger = new FrontendLogger('SYSTEM', '🚀', '#1e3a8a');
|
||||
|
||||
// Default logger for general use
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import {clsx, type ClassValue} from "clsx"
|
||||
import {twMerge} from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"deleteCredential": "Delete Credential",
|
||||
"updateCredential": "Update Credential",
|
||||
"credentialName": "Credential Name",
|
||||
"credentialDescription": "Description",
|
||||
"credentialDescription": "Description",
|
||||
"username": "Username",
|
||||
"searchCredentials": "Search credentials...",
|
||||
"selectFolder": "Select Folder",
|
||||
@@ -42,7 +42,7 @@
|
||||
"credentialsCount": "{{count}} credentials",
|
||||
"refresh": "Refresh",
|
||||
"passwordRequired": "Password is required",
|
||||
"sshKeyRequired": "SSH key is required",
|
||||
"sshKeyRequired": "SSH key is required",
|
||||
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
|
||||
"general": "General",
|
||||
"description": "Description",
|
||||
@@ -57,7 +57,7 @@
|
||||
"keyPassword": "Key Password (optional)",
|
||||
"keyType": "Key Type",
|
||||
"keyTypeRSA": "RSA",
|
||||
"keyTypeECDSA": "ECDSA",
|
||||
"keyTypeECDSA": "ECDSA",
|
||||
"keyTypeEd25519": "Ed25519",
|
||||
"updateCredential": "Update Credential",
|
||||
"basicInfo": "Basic Info",
|
||||
@@ -224,7 +224,7 @@
|
||||
"register": "Register",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"version" : "Version",
|
||||
"version": "Version",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"back": "Back",
|
||||
"email": "Email",
|
||||
|
||||
@@ -2,7 +2,7 @@ import {StrictMode, useEffect, useState, useRef} from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './index.css'
|
||||
import DesktopApp from './ui/Desktop/DesktopApp.tsx'
|
||||
import { MobileApp } from './ui/Mobile/MobileApp.tsx'
|
||||
import {MobileApp} from './ui/Mobile/MobileApp.tsx'
|
||||
import {ThemeProvider} from "@/components/theme-provider"
|
||||
import './i18n/i18n'
|
||||
import {isElectron} from './ui/main-axios.ts'
|
||||
@@ -54,10 +54,10 @@ function RootApp() {
|
||||
const width = useWindowWidth();
|
||||
const isMobile = width < 768;
|
||||
if (isElectron()) {
|
||||
return <DesktopApp />;
|
||||
return <DesktopApp/>;
|
||||
}
|
||||
|
||||
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||
return isMobile ? <MobileApp key="mobile"/> : <DesktopApp key="desktop"/>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
20
src/types/electron.d.ts
vendored
20
src/types/electron.d.ts
vendored
@@ -1,20 +0,0 @@
|
||||
interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
getPlatform: () => Promise<string>;
|
||||
getServerConfig: () => Promise<{ serverUrl: string; lastUpdated: string } | null>;
|
||||
saveServerConfig: (config: { serverUrl: string; lastUpdated: string }) => Promise<{ success: boolean; error?: string }>;
|
||||
testServerConnection: (serverUrl: string) => Promise<{ success: boolean; error?: string; status?: number }>;
|
||||
showSaveDialog: (options: any) => Promise<any>;
|
||||
showOpenDialog: (options: any) => Promise<any>;
|
||||
onUpdateAvailable: (callback: () => void) => void;
|
||||
onUpdateDownloaded: (callback: () => void) => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
isElectron: boolean;
|
||||
isDev: boolean;
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
IS_ELECTRON?: boolean;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// This file contains all shared interfaces and types used across the application
|
||||
// to avoid duplication and ensure consistency.
|
||||
|
||||
import type { Client } from 'ssh2';
|
||||
import type {Client} from 'ssh2';
|
||||
|
||||
// ============================================================================
|
||||
// SSH HOST TYPES
|
||||
|
||||
@@ -74,24 +74,19 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
React.useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (!jwt) return;
|
||||
|
||||
// Check if we're in Electron and have a server configured
|
||||
|
||||
if (isElectron()) {
|
||||
// In Electron, check if we have a configured server
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
console.log('No server configured in Electron, skipping API calls');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getOIDCConfig()
|
||||
.then(res => {
|
||||
if (res) setOidcConfig(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch OIDC config:', err);
|
||||
// Only show error if it's not a "no server configured" error
|
||||
if (!err.message?.includes('No server configured')) {
|
||||
toast.error(t('admin.failedToFetchOidcConfig'));
|
||||
}
|
||||
@@ -100,15 +95,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Check if we're in Electron and have a server configured
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
console.log('No server configured in Electron, skipping registration status check');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getRegistrationAllowed()
|
||||
.then(res => {
|
||||
if (typeof res?.allowed === 'boolean') {
|
||||
@@ -116,8 +109,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch registration status:', err);
|
||||
// Only show error if it's not a "no server configured" error
|
||||
if (!err.message?.includes('No server configured')) {
|
||||
toast.error(t('admin.failedToFetchRegistrationStatus'));
|
||||
}
|
||||
@@ -127,23 +118,19 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
const fetchUsers = async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (!jwt) return;
|
||||
|
||||
// Check if we're in Electron and have a server configured
|
||||
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
console.log('No server configured in Electron, skipping user fetch');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await getUserList();
|
||||
setUsers(response.users);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users:', err);
|
||||
// Only show error if it's not a "no server configured" error
|
||||
if (!err.message?.includes('No server configured')) {
|
||||
toast.error(t('admin.failedToFetchUsers'));
|
||||
}
|
||||
@@ -171,7 +158,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
||||
if (missing.length > 0) {
|
||||
setOidcError(t('admin.missingRequiredFields', { fields: missing.join(', ') }));
|
||||
setOidcError(t('admin.missingRequiredFields', {fields: missing.join(', ')}));
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -199,7 +186,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await makeUserAdmin(newAdminUsername.trim());
|
||||
toast.success(t('admin.userIsNowAdmin', { username: newAdminUsername }));
|
||||
toast.success(t('admin.userIsNowAdmin', {username: newAdminUsername}));
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
@@ -211,15 +198,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
|
||||
const handleRemoveAdminStatus = async (username: string) => {
|
||||
confirmWithToast(
|
||||
t('admin.removeAdminStatus', { username }),
|
||||
t('admin.removeAdminStatus', {username}),
|
||||
async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
toast.success(t('admin.adminStatusRemoved', { username }));
|
||||
toast.success(t('admin.adminStatusRemoved', {username}));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove admin status:', err);
|
||||
toast.error(t('admin.failedToRemoveAdminStatus'));
|
||||
}
|
||||
}
|
||||
@@ -228,15 +214,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
confirmWithToast(
|
||||
t('admin.deleteUser', { username }),
|
||||
t('admin.deleteUser', {username}),
|
||||
async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t('admin.userDeletedSuccessfully', { username }));
|
||||
toast.success(t('admin.userDeletedSuccessfully', {username}));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete user:', err);
|
||||
toast.error(t('admin.failedToDeleteUser'));
|
||||
}
|
||||
},
|
||||
@@ -301,9 +286,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open('https://docs.termix.site/oidc', '_blank')}
|
||||
>
|
||||
@@ -328,8 +313,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
|
||||
<PasswordInput id="client_secret" value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder={t('placeholders.clientSecret')} required/>
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder={t('placeholders.clientSecret')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label>
|
||||
@@ -414,7 +399,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
|
||||
<div
|
||||
className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import {zodResolver} from "@hookform/resolvers/zod"
|
||||
import {Controller, useForm} from "react-hook-form"
|
||||
import {z} from "zod"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { toast } from "sonner"
|
||||
import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios'
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type { Credential, CredentialEditorProps, CredentialData } from '../../../../types/index.js'
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {PasswordInput} from "@/components/ui/password-input"
|
||||
import {ScrollArea} from "@/components/ui/scroll-area"
|
||||
import {Separator} from "@/components/ui/separator"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import React, {useEffect, useRef, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {createCredential, updateCredential, getCredentials, getCredentialDetails} from '@/ui/main-axios'
|
||||
import {useTranslation} from "react-i18next"
|
||||
import type {Credential, CredentialEditorProps, CredentialData} from '../../../../types/index.js'
|
||||
|
||||
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEditorProps) {
|
||||
const {t} = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -64,7 +61,6 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
const fullDetails = await getCredentialDetails(editingCredential.id);
|
||||
setFullCredentialDetails(fullDetails);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credential details:', error);
|
||||
toast.error(t('credentials.failedToFetchCredentialDetails'));
|
||||
}
|
||||
} else {
|
||||
@@ -139,7 +135,6 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
const defaultAuthType = fullCredentialDetails.authType;
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
// Force form reset with a small delay to ensure proper rendering
|
||||
setTimeout(() => {
|
||||
const formData = {
|
||||
name: fullCredentialDetails.name || "",
|
||||
@@ -153,16 +148,15 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
keyPassword: "",
|
||||
keyType: "auto" as const,
|
||||
};
|
||||
|
||||
// Only set the relevant authentication fields based on authType
|
||||
|
||||
if (defaultAuthType === 'password') {
|
||||
formData.password = fullCredentialDetails.password || "";
|
||||
} else if (defaultAuthType === 'key') {
|
||||
formData.key = "existing_key"; // Placeholder to indicate existing key
|
||||
formData.key = "existing_key";
|
||||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||
formData.keyType = (fullCredentialDetails.keyType as any) || "auto" as const;
|
||||
}
|
||||
|
||||
|
||||
form.reset(formData);
|
||||
setTagInput("");
|
||||
}, 100);
|
||||
@@ -222,10 +216,10 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
|
||||
if (editingCredential) {
|
||||
await updateCredential(editingCredential.id, submitData);
|
||||
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: data.name }));
|
||||
toast.success(t('credentials.credentialUpdatedSuccessfully', {name: data.name}));
|
||||
} else {
|
||||
await createCredential(submitData);
|
||||
toast.success(t('credentials.credentialAddedSuccessfully', { name: data.name }));
|
||||
toast.success(t('credentials.credentialAddedSuccessfully', {name: data.name}));
|
||||
}
|
||||
|
||||
if (onFormSubmit) {
|
||||
@@ -233,8 +227,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
|
||||
// Reset form after successful submission
|
||||
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToSaveCredential'));
|
||||
@@ -282,15 +275,15 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
}, [folderDropdownOpen]);
|
||||
|
||||
const keyTypeOptions = [
|
||||
{ value: 'auto', label: t('hosts.autoDetect') },
|
||||
{ value: 'ssh-rsa', label: t('hosts.rsa') },
|
||||
{ value: 'ssh-ed25519', label: t('hosts.ed25519') },
|
||||
{ value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256') },
|
||||
{ value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384') },
|
||||
{ value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521') },
|
||||
{ value: 'ssh-dss', label: t('hosts.dsa') },
|
||||
{ value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256') },
|
||||
{ value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512') },
|
||||
{value: 'auto', label: t('hosts.autoDetect')},
|
||||
{value: 'ssh-rsa', label: t('hosts.rsa')},
|
||||
{value: 'ssh-ed25519', label: t('hosts.ed25519')},
|
||||
{value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256')},
|
||||
{value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384')},
|
||||
{value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521')},
|
||||
{value: 'ssh-dss', label: t('hosts.dsa')},
|
||||
{value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256')},
|
||||
{value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512')},
|
||||
];
|
||||
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
@@ -330,7 +323,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t('credentials.credentialName')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -343,7 +336,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t('credentials.username')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -358,7 +351,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10">
|
||||
<FormLabel>{t('credentials.description')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -371,7 +364,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folder"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10 relative">
|
||||
<FormLabel>{t('credentials.folder')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -416,7 +409,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10 overflow-visible">
|
||||
<FormLabel>{t('credentials.tags')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -482,18 +475,14 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
const newAuthType = value as 'password' | 'key';
|
||||
setAuthTab(newAuthType);
|
||||
form.setValue('authType', newAuthType);
|
||||
|
||||
// Clear ALL authentication fields first
|
||||
|
||||
form.setValue('password', '');
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
form.setValue('keyType', 'auto');
|
||||
|
||||
// Then set only the relevant fields based on auth type
|
||||
|
||||
if (newAuthType === 'password') {
|
||||
// Password fields will be filled by user
|
||||
} else if (newAuthType === 'key') {
|
||||
// Key fields will be filled by user
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
@@ -506,11 +495,12 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('credentials.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={t('placeholders.password')} {...field} />
|
||||
<PasswordInput
|
||||
placeholder={t('placeholders.password')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -521,7 +511,6 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
value={keyInputMethod}
|
||||
onValueChange={(value) => {
|
||||
setKeyInputMethod(value as 'upload' | 'paste');
|
||||
// Clear the other field when switching
|
||||
if (value === 'upload') {
|
||||
form.setValue('key', null);
|
||||
} else {
|
||||
@@ -530,7 +519,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
<TabsList
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger value="upload">{t('hosts.uploadFile')}</TabsTrigger>
|
||||
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -538,7 +528,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -560,8 +550,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
>
|
||||
<span className="truncate"
|
||||
title={field.value?.name || t('credentials.upload')}>
|
||||
{field.value === "existing_key" ? t('hosts.existingKey') :
|
||||
field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
|
||||
{field.value === "existing_key" ? t('hosts.existingKey') :
|
||||
field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -573,7 +563,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>{t('credentials.keyPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -588,7 +578,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>{t('credentials.keyType')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -607,7 +597,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-1 p-0">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
@@ -637,7 +628,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -655,7 +646,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>{t('credentials.keyPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -670,7 +661,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>{t('credentials.keyType')}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -689,7 +680,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-1 p-0">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
|
||||
import { getCredentials } from '@/ui/main-axios.ts';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Credential } from '../../../../types';
|
||||
import React, {useState, useEffect, useRef} from 'react';
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {FormControl, FormItem, FormLabel} from "@/components/ui/form.tsx";
|
||||
import {getCredentials} from '@/ui/main-axios.ts';
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type {Credential} from '../../../../types';
|
||||
|
||||
interface CredentialSelectorProps {
|
||||
value?: number | null;
|
||||
@@ -12,8 +12,8 @@ interface CredentialSelectorProps {
|
||||
onCredentialSelect?: (credential: Credential | null) => void;
|
||||
}
|
||||
|
||||
export function CredentialSelector({ value, onValueChange, onCredentialSelect }: CredentialSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
export function CredentialSelector({value, onValueChange, onCredentialSelect}: CredentialSelectorProps) {
|
||||
const {t} = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
@@ -30,7 +30,6 @@ export function CredentialSelector({ value, onValueChange, onCredentialSelect }:
|
||||
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []);
|
||||
setCredentials(credentialsArray);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credentials:', error);
|
||||
const {toast} = await import('sonner');
|
||||
toast.error(t('credentials.failedToFetchCredentials'));
|
||||
setCredentials([]);
|
||||
@@ -128,7 +127,7 @@ export function CredentialSelector({ value, onValueChange, onCredentialSelect }:
|
||||
t('hosts.selectCredentialPlaceholder')
|
||||
)}
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,36 +1,34 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import {
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet";
|
||||
import {
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
Folder,
|
||||
Edit3,
|
||||
Copy,
|
||||
Settings,
|
||||
Shield,
|
||||
Clock,
|
||||
Server,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Credential, HostInfo, CredentialViewerProps } from '../../../types/index.js';
|
||||
import {getCredentialDetails, getCredentialHosts} from '@/ui/main-axios';
|
||||
import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type {Credential, HostInfo, CredentialViewerProps} from '../../../types/index.js';
|
||||
|
||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => {
|
||||
const { t } = useTranslation();
|
||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose, onEdit}) => {
|
||||
const {t} = useTranslation();
|
||||
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null);
|
||||
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -47,7 +45,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
const response = await getCredentialDetails(credential.id);
|
||||
setCredentialDetails(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credential details:', error);
|
||||
toast.error(t('credentials.failedToFetchCredentialDetails'));
|
||||
}
|
||||
};
|
||||
@@ -57,7 +54,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
const response = await getCredentialHosts(credential.id);
|
||||
setHostsUsing(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hosts using credential:', error);
|
||||
toast.error(t('credentials.failedToFetchHostsUsing'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -74,7 +70,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t('copiedToClipboard', { field: fieldName }));
|
||||
toast.success(t('copiedToClipboard', {field: fieldName}));
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToCopy'));
|
||||
}
|
||||
@@ -86,9 +82,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
|
||||
const getAuthIcon = (authType: string) => {
|
||||
return authType === 'password' ? (
|
||||
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400"/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -114,20 +110,21 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
size="sm"
|
||||
onClick={() => toggleSensitiveVisibility(fieldName)}
|
||||
>
|
||||
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{isVisible ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(value, label)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
|
||||
{isVisible ? (
|
||||
<pre className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
|
||||
<pre
|
||||
className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
|
||||
{value}
|
||||
</pre>
|
||||
) : (
|
||||
@@ -167,11 +164,13 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
<Badge variant="outline"
|
||||
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.authType}
|
||||
</Badge>
|
||||
{credentialDetails.keyType && (
|
||||
<Badge variant="secondary" className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
<Badge variant="secondary"
|
||||
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
{credentialDetails.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -181,14 +180,15 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||
<div
|
||||
className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||
<Button
|
||||
variant={activeTab === 'overview' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
<FileText className="h-4 w-4 mr-2"/>
|
||||
{t('credentials.overview')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -197,7 +197,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
onClick={() => setActiveTab('security')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
<Shield className="h-4 w-4 mr-2"/>
|
||||
{t('credentials.security')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -206,7 +206,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
onClick={() => setActiveTab('usage')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
<Server className="h-4 w-4 mr-2"/>
|
||||
{t('credentials.usage')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -216,24 +216,28 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-8">
|
||||
<CardTitle className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
|
||||
<CardTitle
|
||||
className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div>
|
||||
<div
|
||||
className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.folder && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div>
|
||||
<div className="font-medium">{credentialDetails.folder}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,9 +245,10 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
|
||||
{credentialDetails.tags.length > 0 && (
|
||||
<div className="flex items-start space-x-4">
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1"/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{credentialDetails.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
@@ -255,20 +260,22 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<Separator/>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,19 +297,24 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</div>
|
||||
|
||||
{credentialDetails.lastUsed && (
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div
|
||||
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div>
|
||||
<div
|
||||
className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div
|
||||
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
|
||||
<div className="font-medium">{hostsUsing.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,7 +327,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
<span>{t('credentials.securityDetails')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -323,8 +335,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||
<div
|
||||
className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{t('credentials.credentialSecured')}
|
||||
@@ -345,10 +358,11 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
{credentialDetails.authType === 'key' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3>
|
||||
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
<div
|
||||
className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
{t('credentials.keyType')}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
@@ -358,17 +372,18 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</div>
|
||||
|
||||
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)}
|
||||
|
||||
|
||||
{credentialDetails.keyPassword && renderSensitiveField(
|
||||
credentialDetails.keyPassword,
|
||||
'keyPassword',
|
||||
credentialDetails.keyPassword,
|
||||
'keyPassword',
|
||||
t('credentials.keyPassphrase')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
|
||||
<div
|
||||
className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5"/>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
||||
{t('credentials.securityReminder')}
|
||||
@@ -386,7 +401,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
<span>{t('credentials.hostsUsingCredential')}</span>
|
||||
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||
</CardTitle>
|
||||
@@ -394,20 +409,21 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
<CardContent>
|
||||
{hostsUsing.length === 0 ? (
|
||||
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
|
||||
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600"/>
|
||||
<p>{t('credentials.noHostsUsingCredential')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{hostsUsing.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
<Server
|
||||
className="h-4 w-4 text-zinc-600 dark:text-zinc-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
@@ -418,7 +434,8 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<div
|
||||
className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{formatDate(host.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,7 +453,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit3 className="h-4 w-4 mr-2" />
|
||||
<Edit3 className="h-4 w-4 mr-2"/>
|
||||
{t('credentials.editCredential')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Search,
|
||||
Key,
|
||||
import React, {useState, useEffect, useMemo, useRef} from 'react';
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Search,
|
||||
Key,
|
||||
Folder,
|
||||
Edit,
|
||||
Trash2,
|
||||
@@ -20,16 +20,16 @@ import {
|
||||
X,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { getCredentials, deleteCredential, updateCredential, renameCredentialFolder } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation.ts';
|
||||
import {getCredentials, deleteCredential, updateCredential, renameCredentialFolder} from '@/ui/main-axios';
|
||||
import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useConfirmation} from '@/hooks/use-confirmation.ts';
|
||||
import CredentialViewer from './CredentialViewer';
|
||||
import type { Credential, CredentialsManagerProps } from '../../../../types/index.js';
|
||||
import type {Credential, CredentialsManagerProps} from '../../../../types/index.js';
|
||||
|
||||
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
export function CredentialsManager({onEditCredential}: CredentialsManagerProps) {
|
||||
const {t} = useTranslation();
|
||||
const {confirmWithToast} = useConfirmation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -61,7 +61,6 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleEdit = (credential: Credential) => {
|
||||
if (onEditCredential) {
|
||||
onEditCredential(credential);
|
||||
@@ -71,11 +70,11 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
|
||||
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||
confirmWithToast(
|
||||
t('credentials.confirmDeleteCredential', { name: credentialName }),
|
||||
t('credentials.confirmDeleteCredential', {name: credentialName}),
|
||||
async () => {
|
||||
try {
|
||||
await deleteCredential(credentialId);
|
||||
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
||||
toast.success(t('credentials.credentialDeletedSuccessfully', {name: credentialName}));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (err: any) {
|
||||
@@ -93,13 +92,16 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
|
||||
const handleRemoveFromFolder = async (credential: Credential) => {
|
||||
confirmWithToast(
|
||||
t('credentials.confirmRemoveFromFolder', { name: credential.name || credential.username, folder: credential.folder }),
|
||||
t('credentials.confirmRemoveFromFolder', {
|
||||
name: credential.name || credential.username,
|
||||
folder: credential.folder
|
||||
}),
|
||||
async () => {
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedCredential = { ...credential, folder: '' };
|
||||
const updatedCredential = {...credential, folder: ''};
|
||||
await updateCredential(credential.id, updatedCredential);
|
||||
toast.success(t('credentials.removedFromFolder', { name: credential.name || credential.username }));
|
||||
toast.success(t('credentials.removedFromFolder', {name: credential.name || credential.username}));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (err) {
|
||||
@@ -121,7 +123,7 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
await renameCredentialFolder(oldName, editingFolderName.trim());
|
||||
toast.success(t('credentials.folderRenamed', { oldName, newName: editingFolderName.trim() }));
|
||||
toast.success(t('credentials.folderRenamed', {oldName, newName: editingFolderName.trim()}));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
setEditingFolder(null);
|
||||
@@ -143,11 +145,10 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
setEditingFolderName('');
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (e: React.DragEvent, credential: Credential) => {
|
||||
setDraggedCredential(credential);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', ''); // Required for Firefox
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
@@ -182,7 +183,7 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
if (!draggedCredential) return;
|
||||
|
||||
const newFolder = targetFolder === t('credentials.uncategorized') ? '' : targetFolder;
|
||||
|
||||
|
||||
if (draggedCredential.folder === newFolder) {
|
||||
setDraggedCredential(null);
|
||||
return;
|
||||
@@ -190,11 +191,11 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedCredential = { ...draggedCredential, folder: newFolder };
|
||||
const updatedCredential = {...draggedCredential, folder: newFolder};
|
||||
await updateCredential(draggedCredential.id, updatedCredential);
|
||||
toast.success(t('credentials.movedToFolder', {
|
||||
toast.success(t('credentials.movedToFolder', {
|
||||
name: draggedCredential.name || draggedCredential.username,
|
||||
folder: targetFolder
|
||||
folder: targetFolder
|
||||
}));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
@@ -287,7 +288,7 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('credentials.credentialsCount', { count: 0 })}
|
||||
{t('credentials.credentialsCount', {count: 0})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -316,7 +317,7 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('credentials.credentialsCount', { count: filteredAndSortedCredentials.length })}
|
||||
{t('credentials.credentialsCount', {count: filteredAndSortedCredentials.length})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -339,8 +340,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
|
||||
<div
|
||||
key={folder}
|
||||
<div
|
||||
key={folder}
|
||||
className={`border rounded-md transition-all duration-200 ${
|
||||
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : ''
|
||||
}`}
|
||||
@@ -356,7 +357,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Folder className="h-4 w-4"/>
|
||||
{editingFolder === folder ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editingFolderName}
|
||||
onChange={(e) => setEditingFolderName(e.target.value)}
|
||||
@@ -395,8 +397,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (folder !== t('credentials.uncategorized')) {
|
||||
@@ -471,11 +473,13 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FolderMinus className="h-3 w-3"/>
|
||||
<FolderMinus
|
||||
className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remove from folder "{credential.folder}"</p>
|
||||
<p>Remove from folder
|
||||
"{credential.folder}"</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -538,7 +542,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
{credential.authType === 'password' ? (
|
||||
<Key className="h-2 w-2 mr-0.5"/>
|
||||
) : (
|
||||
@@ -547,7 +552,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.authType === 'key' && credential.keyType && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -558,7 +564,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Click to edit credential</p>
|
||||
<p className="text-xs text-muted-foreground">Drag to move between folders</p>
|
||||
<p className="text-xs text-muted-foreground">Drag to
|
||||
move between folders</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
||||
import {FileManagerTabList} from "./FileManagerTabList.tsx";
|
||||
|
||||
interface FileManagerTopNavbarProps {
|
||||
tabs: {id: string | number, title: string}[];
|
||||
tabs: { id: string | number, title: string }[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
@@ -10,8 +10,8 @@ interface FileManagerTopNavbarProps {
|
||||
}
|
||||
|
||||
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
|
||||
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
|
||||
|
||||
const {tabs, activeTab, setActiveTab, closeTab, onHomeClick} = props;
|
||||
|
||||
return (
|
||||
<FileManagerTabList
|
||||
tabs={tabs}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, {useState, useEffect, useRef} from "react";
|
||||
import {FileManagerLeftSidebar} from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||
import {FileManagerTabList} from "@/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx";
|
||||
import {FileManagerHomeView} from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
|
||||
import {FileManagerFileEditor} from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
|
||||
import {FileManagerOperations} from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx";
|
||||
@@ -8,7 +7,6 @@ import {Button} from '@/components/ui/button.tsx';
|
||||
import {FIleManagerTopNavbar} from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
@@ -26,9 +24,9 @@ import {
|
||||
getSSHStatus,
|
||||
connectSSH
|
||||
} from '@/ui/main-axios.ts';
|
||||
import type { SSHHost, Tab, FileManagerProps } from '../../../types/index.js';
|
||||
import type {SSHHost, Tab} from '../../../types/index.js';
|
||||
|
||||
export function FileManager({onSelectView, embedded = false, initialHost = null, onClose}: {
|
||||
export function FileManager({onSelectView, initialHost = null, onClose}: {
|
||||
onSelectView?: (view: string) => void,
|
||||
embedded?: boolean,
|
||||
initialHost?: SSHHost | null,
|
||||
@@ -122,10 +120,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
|
||||
type: 'directory'
|
||||
})));
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch home data:', err);
|
||||
const {toast} = await import('sonner');
|
||||
toast.error(t('fileManager.failedToFetchHomeData'));
|
||||
// Close the file manager tab on connection failure
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
@@ -371,7 +368,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
|
||||
loading: false
|
||||
} : t));
|
||||
|
||||
// Handle toast notification from backend
|
||||
if (result?.toast) {
|
||||
toast[result.toast.type](result.toast.message);
|
||||
} else {
|
||||
@@ -389,7 +385,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
|
||||
hostId: currentHost.id
|
||||
});
|
||||
} catch (recentErr) {
|
||||
console.error('Failed to add recent file:', recentErr);
|
||||
}
|
||||
})(),
|
||||
]).then(() => {
|
||||
@@ -443,14 +438,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
|
||||
try {
|
||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||
const response = await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
||||
|
||||
// Handle toast notification from backend
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`);
|
||||
}
|
||||
|
||||
|
||||
setDeletingItem(null);
|
||||
handleOperationComplete();
|
||||
} catch (error: any) {
|
||||
@@ -475,7 +469,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
|
||||
onPathChange={updateCurrentPath}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-0 left-64 right-0 bottom-0 flex items-center justify-center bg-dark-bg-darkest">
|
||||
<div
|
||||
className="absolute top-0 left-64 right-0 bottom-0 flex items-center justify-center bg-dark-bg-darkest">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{t('fileManager.connectToServer')}</h2>
|
||||
<p className="text-muted-foreground">{t('fileManager.selectServerToEdit')}</p>
|
||||
@@ -546,7 +541,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-[44px] left-64 right-0 bottom-0 overflow-hidden z-[10] bg-dark-bg-very-light flex flex-col">
|
||||
<div
|
||||
className="absolute top-[44px] left-64 right-0 bottom-0 overflow-hidden z-[10] bg-dark-bg-very-light flex flex-col">
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1">
|
||||
{activeTab === 'home' ? (
|
||||
@@ -605,7 +601,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
|
||||
{t('fileManager.confirmDelete')}
|
||||
</h3>
|
||||
<p className="text-white mb-4">
|
||||
{t('fileManager.confirmDeleteMessage', { name: deletingItem.name })}
|
||||
{t('fileManager.confirmDeleteMessage', {name: deletingItem.name})}
|
||||
{deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`}
|
||||
</p>
|
||||
<p className="text-red-400 text-sm mb-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import React, {useEffect} from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
|
||||
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type { FileItem, ShortcutItem } from '../../../types/index';
|
||||
import type {FileItem, ShortcutItem} from '../../../types/index';
|
||||
|
||||
interface FileManagerHomeViewProps {
|
||||
recent: FileItem[];
|
||||
@@ -111,9 +111,12 @@ export function FileManagerHomeView({
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
|
||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-dark-bg-button">{t('fileManager.recent')}</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-dark-bg-button">{t('fileManager.pinned')}</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-dark-bg-button">{t('fileManager.folderShortcuts')}</TabsTrigger>
|
||||
<TabsTrigger value="recent"
|
||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.recent')}</TabsTrigger>
|
||||
<TabsTrigger value="pinned"
|
||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.pinned')}</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts"
|
||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.folderShortcuts')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react';
|
||||
import {Folder, File, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react';
|
||||
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
@@ -11,18 +10,16 @@ import {
|
||||
listSSHFiles,
|
||||
renameSSHItem,
|
||||
deleteSSHItem,
|
||||
getFileManagerRecent,
|
||||
getFileManagerPinned,
|
||||
addFileManagerPinned,
|
||||
removeFileManagerPinned,
|
||||
readSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH
|
||||
} from '@/ui/main-axios.ts';
|
||||
import type { SSHHost, FileManagerLeftSidebarProps } from '../../../types/index.js';
|
||||
import type {SSHHost} from '../../../types/index.js';
|
||||
|
||||
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
|
||||
{onOpenFile, tabs, host, onOperationComplete, onPathChange, onDeleteItem}: {
|
||||
onSelectView?: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: any[];
|
||||
@@ -55,7 +52,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
||||
const [connectionCache, setConnectionCache] = useState<Record<string, {
|
||||
sessionId: string;
|
||||
@@ -287,7 +283,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x,
|
||||
@@ -297,7 +293,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu({ visible: false, x: 0, y: 0, item: null });
|
||||
setContextMenu({visible: false, x: 0, y: 0, item: null});
|
||||
};
|
||||
|
||||
const handleRename = async (item: any, newName: string) => {
|
||||
@@ -320,24 +316,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: any) => {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
await deleteSSHItem(sshSessionId, item.path, item.type === 'directory');
|
||||
toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.deletedSuccessfully')}`);
|
||||
if (onOperationComplete) {
|
||||
onOperationComplete();
|
||||
} else {
|
||||
fetchFiles();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (item: any) => {
|
||||
setRenamingItem({ item, newName: item.name });
|
||||
setRenamingItem({item, newName: item.name});
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
@@ -360,10 +340,12 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
return (
|
||||
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
|
||||
<div className="flex flex-col flex-grow min-h-0">
|
||||
<div className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
|
||||
<div
|
||||
className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
|
||||
{host && (
|
||||
<div className="flex flex-col h-full w-full max-w-[260px]">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
@@ -405,14 +387,15 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
{connectingSSH || filesLoading ? (
|
||||
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">{t('fileManager.noFilesOrFoldersFound')}</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground">{t('fileManager.noFilesOrFoldersFound')}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredFiles.map((item: any) => {
|
||||
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
||||
const isRenaming = renamingItem?.item?.path === item.path;
|
||||
const isDeleting = false;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
@@ -425,18 +408,23 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
{isRenaming ? (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{item.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
|
||||
<Folder
|
||||
className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||
<File
|
||||
className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
|
||||
<Input
|
||||
value={renamingItem.newName}
|
||||
onChange={(e) => setRenamingItem(prev => prev ? {...prev, newName: e.target.value} : null)}
|
||||
onChange={(e) => setRenamingItem(prev => prev ? {
|
||||
...prev,
|
||||
newName: e.target.value
|
||||
} : null)}
|
||||
className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename(item, renamingItem.newName);
|
||||
} else if (e.key === 'Escape') {
|
||||
setRenamingItem(null);
|
||||
setRenamingItem(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() => handleRename(item, renamingItem.newName)}
|
||||
@@ -454,13 +442,17 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
}))}
|
||||
>
|
||||
{item.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
|
||||
<span className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span>
|
||||
<Folder
|
||||
className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||
<File
|
||||
className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
|
||||
<span
|
||||
className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{item.type === 'file' && (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
<Button size="icon" variant="ghost"
|
||||
className="h-7 w-7"
|
||||
disabled={isOpen}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -474,7 +466,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
sshSessionId: host?.id.toString()
|
||||
});
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? { ...f, isPinned: false } : f
|
||||
f.path === item.path ? {
|
||||
...f,
|
||||
isPinned: false
|
||||
} : f
|
||||
));
|
||||
} else {
|
||||
await addFileManagerPinned({
|
||||
@@ -485,14 +480,18 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
sshSessionId: host?.id.toString()
|
||||
});
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? { ...f, isPinned: true } : f
|
||||
f.path === item.path ? {
|
||||
...f,
|
||||
isPinned: true
|
||||
} : f
|
||||
));
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
<Pin
|
||||
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
{!isOpen && (
|
||||
@@ -505,7 +504,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
handleContextMenu(e, item);
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
<MoreVertical className="w-4 h-4"/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -536,14 +535,14 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2"
|
||||
onClick={() => startRename(contextMenu.item)}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
<Edit3 className="w-4 h-4"/>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2"
|
||||
onClick={() => startDelete(contextMenu.item)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="w-4 h-4"/>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
|
||||
import {Folder, File, Trash2, Pin} from 'lucide-react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface SSHConnection {
|
||||
@@ -43,12 +42,6 @@ interface FileManagerLeftSidebarVileViewerProps {
|
||||
}
|
||||
|
||||
export function FileManagerLeftSidebarFileViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
@@ -58,12 +51,9 @@ export function FileManagerLeftSidebarFileViewer({
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
const {t} = useTranslation();
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
|
||||
|
||||
@@ -10,14 +10,13 @@ import {
|
||||
Trash2,
|
||||
Edit3,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Folder
|
||||
} from 'lucide-react';
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type { FileManagerOperationsProps } from '../../../types/index.js';
|
||||
import type {FileManagerOperationsProps} from '../../../types/index.js';
|
||||
|
||||
export function FileManagerOperations({
|
||||
currentPath,
|
||||
@@ -56,7 +55,7 @@ export function FileManagerOperations({
|
||||
};
|
||||
|
||||
checkContainerWidth();
|
||||
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkContainerWidth);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
@@ -71,32 +70,28 @@ export function FileManagerOperations({
|
||||
if (!uploadFile || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.uploadingFile', { name: uploadFile.name }));
|
||||
|
||||
const loadingToast = toast.loading(t('fileManager.uploadingFile', {name: uploadFile.name}));
|
||||
|
||||
try {
|
||||
const content = await uploadFile.text();
|
||||
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
||||
|
||||
const response = await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
||||
|
||||
// Dismiss loading toast and show success
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name }));
|
||||
onSuccess(t('fileManager.fileUploadedSuccessfully', {name: uploadFile.name}));
|
||||
}
|
||||
|
||||
|
||||
setShowUpload(false);
|
||||
setUploadFile(null);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile'));
|
||||
} finally {
|
||||
@@ -108,31 +103,27 @@ export function FileManagerOperations({
|
||||
if (!newFileName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.creatingFile', { name: newFileName.trim() }));
|
||||
|
||||
const loadingToast = toast.loading(t('fileManager.creatingFile', {name: newFileName.trim()}));
|
||||
|
||||
try {
|
||||
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
||||
|
||||
const response = await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
||||
|
||||
// Dismiss loading toast
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() }));
|
||||
onSuccess(t('fileManager.fileCreatedSuccessfully', {name: newFileName.trim()}));
|
||||
}
|
||||
|
||||
|
||||
setShowCreateFile(false);
|
||||
setNewFileName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile'));
|
||||
} finally {
|
||||
@@ -144,31 +135,27 @@ export function FileManagerOperations({
|
||||
if (!newFolderName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.creatingFolder', { name: newFolderName.trim() }));
|
||||
|
||||
const loadingToast = toast.loading(t('fileManager.creatingFolder', {name: newFolderName.trim()}));
|
||||
|
||||
try {
|
||||
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
||||
|
||||
const response = await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
||||
|
||||
// Dismiss loading toast
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() }));
|
||||
onSuccess(t('fileManager.folderCreatedSuccessfully', {name: newFolderName.trim()}));
|
||||
}
|
||||
|
||||
|
||||
setShowCreateFolder(false);
|
||||
setNewFolderName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder'));
|
||||
} finally {
|
||||
@@ -180,35 +167,31 @@ export function FileManagerOperations({
|
||||
if (!deletePath || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.deletingItem', {
|
||||
const loadingToast = toast.loading(t('fileManager.deletingItem', {
|
||||
type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file'),
|
||||
name: deletePath.split('/').pop()
|
||||
}));
|
||||
|
||||
|
||||
try {
|
||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||
|
||||
const response = await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
||||
|
||||
// Dismiss loading toast
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
||||
onSuccess(t('fileManager.itemDeletedSuccessfully', {type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file')}));
|
||||
}
|
||||
|
||||
|
||||
setShowDelete(false);
|
||||
setDeletePath('');
|
||||
setDeleteIsDirectory(false);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
||||
} finally {
|
||||
@@ -220,37 +203,33 @@ export function FileManagerOperations({
|
||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.renamingItem', {
|
||||
const loadingToast = toast.loading(t('fileManager.renamingItem', {
|
||||
type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file'),
|
||||
oldName: renamePath.split('/').pop(),
|
||||
newName: newName.trim()
|
||||
}));
|
||||
|
||||
|
||||
try {
|
||||
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
||||
|
||||
const response = await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
||||
|
||||
// Dismiss loading toast
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
||||
onSuccess(t('fileManager.itemRenamedSuccessfully', {type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file')}));
|
||||
}
|
||||
|
||||
|
||||
setShowRename(false);
|
||||
setRenamePath('');
|
||||
setRenameIsDirectory(false);
|
||||
setNewName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
|
||||
} finally {
|
||||
@@ -577,7 +556,8 @@ export function FileManagerOperations({
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
|
||||
<span className="text-sm font-medium break-words">{t('fileManager.warningCannotUndo')}</span>
|
||||
<span
|
||||
className="text-sm font-medium break-words">{t('fileManager.warningCannotUndo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManag
|
||||
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type { SSHHost, HostManagerProps } from '../../../types/index';
|
||||
import type {SSHHost, HostManagerProps} from '../../../types/index';
|
||||
|
||||
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
@@ -40,7 +40,6 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
// Reset editing states when switching away from edit tabs
|
||||
if (value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
@@ -95,7 +94,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
||||
<CredentialsManager onEditCredential={handleEditCredential} />
|
||||
<CredentialsManager onEditCredential={handleEditCredential}/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0">
|
||||
|
||||
@@ -3,15 +3,7 @@ import {Controller, useForm} from "react-hook-form"
|
||||
import {z} from "zod"
|
||||
|
||||
import {Button} from "@/components/ui/button.tsx"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel,} from "@/components/ui/form.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {PasswordInput} from "@/components/ui/password-input.tsx";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx"
|
||||
@@ -21,7 +13,7 @@ import React, {useEffect, useRef, useState} from "react";
|
||||
import {Switch} from "@/components/ui/switch.tsx";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import {toast} from "sonner";
|
||||
import {createSSHHost, updateSSHHost, getSSHHosts, getCredentials} from '@/ui/main-axios.ts';
|
||||
import {createSSHHost, getCredentials, getSSHHosts, updateSSHHost} from '@/ui/main-axios.ts';
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {CredentialSelector} from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx";
|
||||
|
||||
@@ -65,8 +57,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
|
||||
const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload');
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
// Ref for the IP address input to manage focus
|
||||
|
||||
const ipInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,7 +94,6 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Listen for credential changes to refresh the credential list
|
||||
useEffect(() => {
|
||||
const handleCredentialChange = async () => {
|
||||
try {
|
||||
@@ -126,14 +116,13 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
setFolders(uniqueFolders);
|
||||
setSshConfigurations(uniqueConfigurations);
|
||||
} catch (error) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener('credentials:changed', handleCredentialChange);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('credentials:changed', handleCredentialChange);
|
||||
};
|
||||
@@ -247,7 +236,6 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
}
|
||||
});
|
||||
|
||||
// Update username when switching to credential tab and a credential is selected
|
||||
useEffect(() => {
|
||||
if (authTab === 'credential') {
|
||||
const currentCredentialId = form.getValues('credentialId');
|
||||
@@ -262,7 +250,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
|
||||
useEffect(() => {
|
||||
if (editingHost) {
|
||||
const cleanedHost = { ...editingHost };
|
||||
const cleanedHost = {...editingHost};
|
||||
if (cleanedHost.credentialId && cleanedHost.key) {
|
||||
cleanedHost.key = undefined;
|
||||
cleanedHost.keyPassword = undefined;
|
||||
@@ -272,10 +260,10 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
} else if (cleanedHost.key && cleanedHost.password) {
|
||||
cleanedHost.password = undefined;
|
||||
}
|
||||
|
||||
|
||||
const defaultAuthType = cleanedHost.credentialId ? 'credential' : (cleanedHost.key ? 'key' : 'password');
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
|
||||
const formData = {
|
||||
name: cleanedHost.name || "",
|
||||
ip: cleanedHost.ip || "",
|
||||
@@ -296,12 +284,11 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
defaultPath: cleanedHost.defaultPath || "/",
|
||||
tunnelConnections: cleanedHost.tunnelConnections || [],
|
||||
};
|
||||
|
||||
// Only set the relevant authentication fields based on authType
|
||||
|
||||
if (defaultAuthType === 'password') {
|
||||
formData.password = cleanedHost.password || "";
|
||||
} else if (defaultAuthType === 'key') {
|
||||
formData.key = "existing_key"; // Placeholder to indicate existing key
|
||||
formData.key = "existing_key";
|
||||
formData.keyPassword = cleanedHost.keyPassword || "";
|
||||
formData.keyType = (cleanedHost.keyType as any) || "auto";
|
||||
} else if (defaultAuthType === 'credential') {
|
||||
@@ -349,7 +336,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
isSubmittingRef.current = true;
|
||||
|
||||
|
||||
if (!data.name || data.name.trim() === '') {
|
||||
data.name = `${data.username}@${data.ip}`;
|
||||
}
|
||||
@@ -399,23 +386,22 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
|
||||
if (editingHost) {
|
||||
const updatedHost = await updateSSHHost(editingHost.id, submitData);
|
||||
toast.success(t('hosts.hostUpdatedSuccessfully', { name: data.name }));
|
||||
|
||||
toast.success(t('hosts.hostUpdatedSuccessfully', {name: data.name}));
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit(updatedHost);
|
||||
}
|
||||
} else {
|
||||
const newHost = await createSSHHost(submitData);
|
||||
toast.success(t('hosts.hostAddedSuccessfully', { name: data.name }));
|
||||
|
||||
toast.success(t('hosts.hostAddedSuccessfully', {name: data.name}));
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit(newHost);
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
|
||||
// Reset form after successful submission
|
||||
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error(t('hosts.failedToSaveHost'));
|
||||
@@ -574,8 +560,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
<FormItem className="col-span-5">
|
||||
<FormLabel>{t('hosts.ipAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('placeholders.ipAddress')}
|
||||
<Input
|
||||
placeholder={t('placeholders.ipAddress')}
|
||||
{...field}
|
||||
ref={(e) => {
|
||||
field.ref(e);
|
||||
@@ -745,8 +731,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
const newAuthType = value as 'password' | 'key' | 'credential';
|
||||
setAuthTab(newAuthType);
|
||||
form.setValue('authType', newAuthType);
|
||||
|
||||
// Clear authentication fields based on what we're switching away from
|
||||
|
||||
if (newAuthType === 'password') {
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
@@ -773,11 +758,12 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('hosts.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={t('placeholders.password')} {...field} />
|
||||
<PasswordInput
|
||||
placeholder={t('placeholders.password')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -788,7 +774,6 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
value={keyInputMethod}
|
||||
onValueChange={(value) => {
|
||||
setKeyInputMethod(value as 'upload' | 'paste');
|
||||
// Clear the other field when switching
|
||||
if (value === 'upload') {
|
||||
form.setValue('key', null);
|
||||
} else {
|
||||
@@ -797,7 +782,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
<TabsList
|
||||
className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger value="upload">{t('hosts.uploadFile')}</TabsTrigger>
|
||||
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -827,8 +813,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
>
|
||||
<span className="truncate"
|
||||
title={field.value?.name || t('hosts.upload')}>
|
||||
{field.value === "existing_key" ? t('hosts.existingKey') :
|
||||
field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
|
||||
{field.value === "existing_key" ? t('hosts.existingKey') :
|
||||
field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -856,7 +842,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -925,14 +911,13 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="credentialId"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onCredentialSelect={(credential) => {
|
||||
if (credential) {
|
||||
// Update username when credential is selected
|
||||
form.setValue('username', credential.username);
|
||||
}
|
||||
}}
|
||||
@@ -1002,7 +987,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
sshpass</code> or <code
|
||||
className="bg-muted px-1 rounded inline">sudo dnf install
|
||||
sshpass</code></div>
|
||||
<div>• {t('hosts.macos')} <code className="bg-muted px-1 rounded inline">brew
|
||||
<div>• {t('hosts.macos')} <code
|
||||
className="bg-muted px-1 rounded inline">brew
|
||||
install hudochenkov/sshpass/sshpass</code></div>
|
||||
<div>• {t('hosts.windows')}</div>
|
||||
</div>
|
||||
@@ -1026,9 +1012,9 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-3 flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open('https://docs.termix.site/tunnels', '_blank')}
|
||||
>
|
||||
@@ -1148,7 +1134,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t('hosts.tunnelForwardDescription', {
|
||||
{t('hosts.tunnelForwardDescription', {
|
||||
sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22',
|
||||
endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224'
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, {useState, useEffect, useMemo, useRef} from "react";
|
||||
import {Card, CardContent} from "@/components/ui/card.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
@@ -22,14 +21,12 @@ import {
|
||||
FileEdit,
|
||||
Search,
|
||||
Upload,
|
||||
Info,
|
||||
X,
|
||||
Check,
|
||||
Pencil,
|
||||
FolderMinus
|
||||
} from "lucide-react";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import type { SSHHost, SSHManagerHostViewerProps } from '../../../../types/index.js';
|
||||
import type {SSHHost, SSHManagerHostViewerProps} from '../../../../types/index.js';
|
||||
|
||||
export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const {t} = useTranslation();
|
||||
@@ -48,16 +45,15 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
|
||||
// Listen for refresh events from other components
|
||||
|
||||
const handleHostsRefresh = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener('hosts:refresh', handleHostsRefresh);
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsRefresh);
|
||||
window.addEventListener('folders:changed', handleHostsRefresh);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hosts:refresh', handleHostsRefresh);
|
||||
window.removeEventListener('ssh-hosts:changed', handleHostsRefresh);
|
||||
@@ -69,9 +65,9 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getSSHHosts();
|
||||
|
||||
|
||||
const cleanedHosts = data.map(host => {
|
||||
const cleanedHost = { ...host };
|
||||
const cleanedHost = {...host};
|
||||
if (cleanedHost.credentialId && cleanedHost.key) {
|
||||
cleanedHost.key = undefined;
|
||||
cleanedHost.keyPassword = undefined;
|
||||
@@ -86,7 +82,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
}
|
||||
return cleanedHost;
|
||||
});
|
||||
|
||||
|
||||
setHosts(cleanedHosts);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -98,11 +94,11 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
confirmWithToast(
|
||||
t('hosts.confirmDelete', { name: hostName }),
|
||||
t('hosts.confirmDelete', {name: hostName}),
|
||||
async () => {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
|
||||
toast.success(t('hosts.hostDeletedSuccessfully', {name: hostName}));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
@@ -115,41 +111,38 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
|
||||
const handleExport = (host: SSHHost) => {
|
||||
const actualAuthType = host.credentialId ? 'credential' : (host.key ? 'key' : 'password');
|
||||
|
||||
// Check if host uses sensitive authentication data
|
||||
|
||||
if (actualAuthType === 'credential') {
|
||||
const confirmMessage = t('hosts.exportCredentialWarning', {
|
||||
name: host.name || `${host.username}@${host.ip}`
|
||||
const confirmMessage = t('hosts.exportCredentialWarning', {
|
||||
name: host.name || `${host.username}@${host.ip}`
|
||||
});
|
||||
|
||||
|
||||
confirmWithToast(confirmMessage, () => {
|
||||
performExport(host, actualAuthType);
|
||||
});
|
||||
return;
|
||||
} else if (actualAuthType === 'password' || actualAuthType === 'key') {
|
||||
const confirmMessage = t('hosts.exportSensitiveDataWarning', {
|
||||
name: host.name || `${host.username}@${host.ip}`
|
||||
const confirmMessage = t('hosts.exportSensitiveDataWarning', {
|
||||
name: host.name || `${host.username}@${host.ip}`
|
||||
});
|
||||
|
||||
|
||||
confirmWithToast(confirmMessage, () => {
|
||||
performExport(host, actualAuthType);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No sensitive data, proceed directly
|
||||
|
||||
performExport(host, actualAuthType);
|
||||
};
|
||||
|
||||
const performExport = (host: SSHHost, actualAuthType: string) => {
|
||||
|
||||
// Create export data with sensitive fields excluded
|
||||
const exportData: any = {
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: actualAuthType, // Use the determined authType, not the stored one
|
||||
authType: actualAuthType,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: host.pin,
|
||||
@@ -160,18 +153,16 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
tunnelConnections: host.tunnelConnections,
|
||||
};
|
||||
|
||||
// Only include credentialId if actualAuthType is credential, but set it to null for security
|
||||
if (actualAuthType === 'credential') {
|
||||
exportData.credentialId = null; // Set to null instead of undefined so it's included but empty
|
||||
exportData.credentialId = null;
|
||||
}
|
||||
|
||||
// Remove undefined values from export, but keep null values
|
||||
const cleanExportData = Object.fromEntries(
|
||||
Object.entries(exportData).filter(([_, value]) => value !== undefined)
|
||||
);
|
||||
|
||||
|
||||
const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], { type: 'application/json' });
|
||||
const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], {type: 'application/json'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -193,13 +184,13 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
|
||||
const handleRemoveFromFolder = async (host: SSHHost) => {
|
||||
confirmWithToast(
|
||||
t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }),
|
||||
t('hosts.confirmRemoveFromFolder', {name: host.name || `${host.username}@${host.ip}`, folder: host.folder}),
|
||||
async () => {
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedHost = { ...host, folder: '' };
|
||||
const updatedHost = {...host, folder: ''};
|
||||
await updateSSHHost(host.id, updatedHost);
|
||||
toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` }));
|
||||
toast.success(t('hosts.removedFromFolder', {name: host.name || `${host.username}@${host.ip}`}));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
@@ -221,7 +212,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
await renameFolder(oldName, editingFolderName.trim());
|
||||
toast.success(t('hosts.folderRenamed', { oldName, newName: editingFolderName.trim() }));
|
||||
toast.success(t('hosts.folderRenamed', {oldName, newName: editingFolderName.trim()}));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
setEditingFolder(null);
|
||||
@@ -243,11 +234,10 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
setEditingFolderName('');
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (e: React.DragEvent, host: SSHHost) => {
|
||||
setDraggedHost(host);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', ''); // Required for Firefox
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
@@ -282,7 +272,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
if (!draggedHost) return;
|
||||
|
||||
const newFolder = targetFolder === t('hosts.uncategorized') ? '' : targetFolder;
|
||||
|
||||
|
||||
if (draggedHost.folder === newFolder) {
|
||||
setDraggedHost(null);
|
||||
return;
|
||||
@@ -290,11 +280,11 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedHost = { ...draggedHost, folder: newFolder };
|
||||
const updatedHost = {...draggedHost, folder: newFolder};
|
||||
await updateSSHHost(draggedHost.id, updatedHost);
|
||||
toast.success(t('hosts.movedToFolder', {
|
||||
toast.success(t('hosts.movedToFolder', {
|
||||
name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`,
|
||||
folder: targetFolder
|
||||
folder: targetFolder
|
||||
}));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
@@ -332,7 +322,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const result = await bulkImportSSHHosts(hostsArray);
|
||||
|
||||
if (result.success > 0) {
|
||||
toast.success(t('hosts.importCompleted', { success: result.success, failed: result.failed }));
|
||||
toast.success(t('hosts.importCompleted', {success: result.success, failed: result.failed}));
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(`Import errors: ${result.errors.join(', ')}`);
|
||||
}
|
||||
@@ -436,7 +426,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('hosts.hostsCount', { count: 0 })}
|
||||
{t('hosts.hostsCount', {count: 0})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -469,66 +459,66 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const sampleData = {
|
||||
hosts: [
|
||||
{
|
||||
name: "Web Server - Production",
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/var/www"
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
username: "dbadmin",
|
||||
authType: "key",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
tags: ["database", "production", "postgresql"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: false,
|
||||
tunnelConnections: [
|
||||
const sampleData = {
|
||||
hosts: [
|
||||
{
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: "Web Server - Production",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true
|
||||
name: "Web Server - Production",
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/var/www"
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
username: "dbadmin",
|
||||
authType: "key",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
tags: ["database", "production", "postgresql"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: false,
|
||||
tunnelConnections: [
|
||||
{
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: "Web Server - Production",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Development Server",
|
||||
ip: "192.168.1.102",
|
||||
port: 2222,
|
||||
username: "developer",
|
||||
authType: "credential",
|
||||
credentialId: 1,
|
||||
folder: "Development",
|
||||
tags: ["dev", "testing"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/home/developer"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Development Server",
|
||||
ip: "192.168.1.102",
|
||||
port: 2222,
|
||||
username: "developer",
|
||||
authType: "credential",
|
||||
credentialId: 1,
|
||||
folder: "Development",
|
||||
tags: ["dev", "testing"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/home/developer"
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -590,7 +580,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('hosts.hostsCount', { count: filteredAndSortedHosts.length })}
|
||||
{t('hosts.hostsCount', {count: filteredAndSortedHosts.length})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -738,8 +728,8 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||
<div
|
||||
key={folder}
|
||||
<div
|
||||
key={folder}
|
||||
className={`border rounded-md transition-all duration-200 ${
|
||||
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : ''
|
||||
}`}
|
||||
@@ -755,7 +745,8 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Folder className="h-4 w-4"/>
|
||||
{editingFolder === folder ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editingFolderName}
|
||||
onChange={(e) => setEditingFolderName(e.target.value)}
|
||||
@@ -794,8 +785,8 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (folder !== t('hosts.uncategorized')) {
|
||||
@@ -851,143 +842,149 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{host.folder && host.folder !== '' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFolder(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FolderMinus className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remove from folder "{host.folder}"</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 hover:bg-blue-500/10"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExport(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10"
|
||||
>
|
||||
<Upload className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Export host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{host.folder && host.folder !== '' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFolder(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FolderMinus
|
||||
className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remove from folder
|
||||
"{host.folder}"</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 hover:bg-blue-500/10"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExport(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10"
|
||||
>
|
||||
<Upload className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Export host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 6 && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Terminal className="h-2 w-2 mr-0.5"/>
|
||||
{t('hosts.terminalBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Network className="h-2 w-2 mr-0.5"/>
|
||||
{t('hosts.tunnelBadge')}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||
<span
|
||||
className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
<div className="mt-2 space-y-1">
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 6 && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||
{t('hosts.fileManagerBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Terminal className="h-2 w-2 mr-0.5"/>
|
||||
{t('hosts.terminalBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Network className="h-2 w-2 mr-0.5"/>
|
||||
{t('hosts.tunnelBadge')}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||
<span
|
||||
className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||
{t('hosts.fileManagerBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Click to edit host</p>
|
||||
<p className="text-xs text-muted-foreground">Drag to move between folders</p>
|
||||
<p className="text-xs text-muted-foreground">Drag to
|
||||
move between folders</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -50,7 +50,6 @@ export function Server({
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest host config:', error);
|
||||
toast.error(t('serverStats.failedToFetchHostConfig'));
|
||||
}
|
||||
}
|
||||
@@ -68,7 +67,6 @@ export function Server({
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch updated host config:', error);
|
||||
toast.error(t('serverStats.failedToFetchHostConfig'));
|
||||
}
|
||||
}
|
||||
@@ -89,20 +87,14 @@ export function Server({
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch server status:', error);
|
||||
if (!cancelled) {
|
||||
// Handle different error types from the new backend
|
||||
if (error?.response?.status === 503) {
|
||||
// Server is offline
|
||||
setServerStatus('offline');
|
||||
} else if (error?.response?.status === 504) {
|
||||
// Timeout - treat as degraded
|
||||
setServerStatus('offline');
|
||||
} else if (error?.response?.status === 404) {
|
||||
// Host not found
|
||||
setServerStatus('offline');
|
||||
} else {
|
||||
// Other errors - treat as offline
|
||||
setServerStatus('offline');
|
||||
}
|
||||
toast.error(t('serverStats.failedToFetchStatus'));
|
||||
@@ -119,7 +111,6 @@ export function Server({
|
||||
setMetrics(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch server metrics:', error);
|
||||
if (!cancelled) {
|
||||
setMetrics(null);
|
||||
toast.error(t('serverStats.failedToFetchMetrics'));
|
||||
@@ -154,8 +145,8 @@ export function Server({
|
||||
|
||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||
if (!currentHostConfig) return false;
|
||||
return tabs.some((tab: any) =>
|
||||
tab.type === 'file_manager' &&
|
||||
return tabs.some((tab: any) =>
|
||||
tab.type === 'file_manager' &&
|
||||
tab.hostConfig?.id === currentHostConfig.id
|
||||
);
|
||||
}, [tabs, currentHostConfig]);
|
||||
@@ -204,18 +195,13 @@ export function Server({
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
setMetrics(data);
|
||||
} catch (error: any) {
|
||||
// Handle different error types from the new backend
|
||||
if (error?.response?.status === 503) {
|
||||
// Server is offline
|
||||
setServerStatus('offline');
|
||||
} else if (error?.response?.status === 504) {
|
||||
// Timeout - treat as offline
|
||||
setServerStatus('offline');
|
||||
} else if (error?.response?.status === 404) {
|
||||
// Host not found
|
||||
setServerStatus('offline');
|
||||
} else {
|
||||
// Other errors - treat as offline
|
||||
setServerStatus('offline');
|
||||
}
|
||||
setMetrics(null);
|
||||
@@ -228,7 +214,8 @@ export function Server({
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{t('serverStats.refreshing')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -265,14 +252,16 @@ export function Server({
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">{t('serverStats.loadingMetrics')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !metrics && serverStatus === 'offline' ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div
|
||||
className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">{t('serverStats.serverOffline')}</p>
|
||||
@@ -281,15 +270,16 @@ export function Server({
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{/* CPU Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.cpuUsage')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
{/* CPU Stats */}
|
||||
<div
|
||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400"/>
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.cpuUsage')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
@@ -299,33 +289,34 @@ export function Server({
|
||||
return `${pctText} ${t('serverStats.of')} ${coresText}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{metrics?.cpu?.load ?
|
||||
`Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` :
|
||||
'Load: N/A'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.memoryUsage')}</h3>
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{metrics?.cpu?.load ?
|
||||
`Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` :
|
||||
'Load: N/A'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
|
||||
{/* Memory Stats */}
|
||||
<div
|
||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400"/>
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.memoryUsage')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
@@ -337,35 +328,36 @@ export function Server({
|
||||
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const free = (typeof used === 'number' && typeof total === 'number') ? (total - used).toFixed(1) : 'N/A';
|
||||
return `Free: ${free} GiB`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.rootStorageSpace')}</h3>
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const free = (typeof used === 'number' && typeof total === 'number') ? (total - used).toFixed(1) : 'N/A';
|
||||
return `Free: ${free} GiB`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
|
||||
{/* Disk Stats */}
|
||||
<div
|
||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400"/>
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.rootStorageSpace')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
@@ -377,25 +369,25 @@ export function Server({
|
||||
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
return used && total ? `Available: ${total}` : 'Available: N/A';
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
return used && total ? `Available: ${total}` : 'Available: N/A';
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false); // Clear connecting state
|
||||
setIsConnecting(false);
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
@@ -138,59 +138,48 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
|
||||
function attemptReconnection() {
|
||||
// Don't attempt reconnection if component is unmounting, shouldn't reconnect, or already reconnecting
|
||||
if (isUnmountingRef.current || shouldNotReconnectRef.current || isReconnectingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already reached max attempts
|
||||
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
||||
toast.error(t('terminal.maxReconnectAttemptsReached'));
|
||||
// Close the terminal tab when max attempts reached
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Set reconnecting flag to prevent multiple simultaneous attempts
|
||||
isReconnectingRef.current = true;
|
||||
|
||||
// Clear terminal immediately to prevent showing last line
|
||||
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
// Increment attempt counter
|
||||
|
||||
reconnectAttempts.current++;
|
||||
|
||||
// Show toast with current attempt number
|
||||
toast.info(t('terminal.reconnecting', { attempt: reconnectAttempts.current, max: maxReconnectAttempts }));
|
||||
|
||||
|
||||
toast.info(t('terminal.reconnecting', {attempt: reconnectAttempts.current, max: maxReconnectAttempts}));
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
// Check again if component is still mounted and should reconnect
|
||||
if (isUnmountingRef.current || shouldNotReconnectRef.current) {
|
||||
isReconnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we haven't exceeded max attempts during the timeout
|
||||
|
||||
if (reconnectAttempts.current > maxReconnectAttempts) {
|
||||
isReconnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (terminal && hostConfig) {
|
||||
// Ensure terminal is clear before reconnecting
|
||||
terminal.clear();
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
connectToHost(cols, rows);
|
||||
}
|
||||
|
||||
// Reset reconnecting flag after attempting connection
|
||||
|
||||
isReconnectingRef.current = false;
|
||||
}, 2000 * reconnectAttempts.current); // Exponential backoff
|
||||
}, 2000 * reconnectAttempts.current);
|
||||
}
|
||||
|
||||
function connectToHost(cols: number, rows: number) {
|
||||
@@ -200,39 +189,30 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8082'
|
||||
: isElectron
|
||||
? (() => {
|
||||
// Get configured server URL from window object (set by main-axios)
|
||||
const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081';
|
||||
// Convert HTTP/HTTPS to WS/WSS and use nginx reverse proxy path
|
||||
const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://';
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, ''); // Keep the port
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
? (() => {
|
||||
const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081';
|
||||
const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://';
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, '');
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
setConnectionError(null);
|
||||
shouldNotReconnectRef.current = false; // Reset reconnection flag
|
||||
isReconnectingRef.current = false; // Reset reconnecting flag
|
||||
setIsConnecting(true); // Set connecting state
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(true);
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||
ws.addEventListener('open', () => {
|
||||
// Don't set isConnected to true here - wait for actual SSH connection
|
||||
// Don't show reconnected toast here - wait for actual connection confirmation
|
||||
|
||||
// Set a timeout for SSH connection establishment
|
||||
connectionTimeoutRef.current = setTimeout(() => {
|
||||
if (!isConnected) {
|
||||
// SSH connection didn't establish within timeout
|
||||
// Clear terminal immediately when connection times out
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
@@ -240,18 +220,17 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
// Attempt reconnection if this was a reconnection attempt
|
||||
if (reconnectAttempts.current > 0) {
|
||||
attemptReconnection();
|
||||
}
|
||||
}
|
||||
}, 10000); // 10 second timeout for SSH connection
|
||||
|
||||
}, 10000);
|
||||
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({type: 'input', data}));
|
||||
});
|
||||
|
||||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({type: 'ping'}));
|
||||
@@ -265,73 +244,59 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
if (msg.type === 'data') {
|
||||
terminal.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
// Handle different types of errors
|
||||
const errorMessage = msg.message || t('terminal.unknownError');
|
||||
|
||||
// Check if it's an authentication error
|
||||
if (errorMessage.toLowerCase().includes('auth') ||
|
||||
|
||||
if (errorMessage.toLowerCase().includes('auth') ||
|
||||
errorMessage.toLowerCase().includes('password') ||
|
||||
errorMessage.toLowerCase().includes('permission') ||
|
||||
errorMessage.toLowerCase().includes('denied') ||
|
||||
errorMessage.toLowerCase().includes('invalid') ||
|
||||
errorMessage.toLowerCase().includes('failed') ||
|
||||
errorMessage.toLowerCase().includes('incorrect')) {
|
||||
toast.error(t('terminal.authError', { message: errorMessage }));
|
||||
shouldNotReconnectRef.current = true; // Don't reconnect on auth errors
|
||||
// Close terminal on auth errors
|
||||
toast.error(t('terminal.authError', {message: errorMessage}));
|
||||
shouldNotReconnectRef.current = true;
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
// Close the terminal tab immediately
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a connection error that should trigger reconnection
|
||||
|
||||
if (errorMessage.toLowerCase().includes('connection') ||
|
||||
errorMessage.toLowerCase().includes('timeout') ||
|
||||
errorMessage.toLowerCase().includes('network')) {
|
||||
toast.error(t('terminal.connectionError', { message: errorMessage }));
|
||||
toast.error(t('terminal.connectionError', {message: errorMessage}));
|
||||
setIsConnected(false);
|
||||
// Clear terminal immediately when connection error occurs
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
// Set connecting state immediately for reconnection
|
||||
setIsConnecting(true);
|
||||
attemptReconnection();
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, show toast but don't close terminal
|
||||
toast.error(t('terminal.error', { message: errorMessage }));
|
||||
|
||||
toast.error(t('terminal.error', {message: errorMessage}));
|
||||
} else if (msg.type === 'connected') {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false); // Clear connecting state
|
||||
// Clear connection timeout since SSH connection is established
|
||||
setIsConnecting(false);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
// Show reconnected toast if this was a reconnection attempt
|
||||
if (reconnectAttempts.current > 0) {
|
||||
toast.success(t('terminal.reconnected'));
|
||||
}
|
||||
// Reset reconnection counter and flags on successful connection
|
||||
reconnectAttempts.current = 0;
|
||||
isReconnectingRef.current = false;
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
setIsConnected(false);
|
||||
// Clear terminal immediately when disconnected
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
// Set connecting state immediately for reconnection
|
||||
setIsConnecting(true);
|
||||
// Attempt reconnection for disconnections
|
||||
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
|
||||
attemptReconnection();
|
||||
}
|
||||
@@ -343,28 +308,22 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
ws.addEventListener('close', (event) => {
|
||||
setIsConnected(false);
|
||||
// Clear terminal immediately when connection closes
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
// Set connecting state immediately for reconnection
|
||||
setIsConnecting(true);
|
||||
if (!wasDisconnectedBySSH.current && !isUnmountingRef.current && !shouldNotReconnectRef.current) {
|
||||
// Attempt reconnection for unexpected disconnections
|
||||
attemptReconnection();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
ws.addEventListener('error', (event) => {
|
||||
setIsConnected(false);
|
||||
setConnectionError(t('terminal.websocketError'));
|
||||
// Clear terminal immediately when WebSocket error occurs
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
// Set connecting state immediately for reconnection
|
||||
setIsConnecting(true);
|
||||
// Attempt reconnection for WebSocket errors
|
||||
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
|
||||
attemptReconnection();
|
||||
}
|
||||
@@ -486,23 +445,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8082'
|
||||
: isElectron
|
||||
? (() => {
|
||||
// Get configured server URL from window object (set by main-axios)
|
||||
const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081';
|
||||
// Convert HTTP/HTTPS to WS/WSS and use nginx reverse proxy path
|
||||
const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://';
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, '').replace(/:\d+$/, ''); // Remove port if present
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
connectToHost(cols, rows);
|
||||
}, 300);
|
||||
});
|
||||
@@ -511,7 +453,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
isUnmountingRef.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(false); // Clear connecting state
|
||||
setIsConnecting(false);
|
||||
resizeObserver.disconnect();
|
||||
element?.removeEventListener('contextmenu', handleContextMenu);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
@@ -536,7 +478,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
@@ -560,8 +502,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
return (
|
||||
<div className="h-full w-full m-1 relative">
|
||||
{/* Terminal */}
|
||||
<div
|
||||
ref={xtermRef}
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? 'opacity-100' : 'opacity-0'} overflow-hidden`}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
@@ -569,12 +511,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* Connecting State */}
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">{t('terminal.connecting')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useState, useEffect, useCallback} from "react";
|
||||
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
||||
import type { SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps } from '../../../types/index.js';
|
||||
import type {SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps} from '../../../types/index.js';
|
||||
|
||||
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React from "react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Card} from "@/components/ui/card.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
Terminal,
|
||||
Network,
|
||||
FileEdit,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
@@ -16,11 +14,10 @@ import {
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Zap,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import type { SSHHost, TunnelConnection, TunnelStatus, CONNECTION_STATES, SSHTunnelObjectProps } from '../../../types/index.js';
|
||||
import type {TunnelStatus, SSHTunnelObjectProps} from '../../../types/index.js';
|
||||
|
||||
export function TunnelObject({
|
||||
host,
|
||||
@@ -227,9 +224,12 @@ export function TunnelObject({
|
||||
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
|
||||
</div>
|
||||
<div>
|
||||
{t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })}
|
||||
{t('tunnels.attempt', {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span> • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })}</span>
|
||||
<span> • {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,9 +408,12 @@ export function TunnelObject({
|
||||
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
|
||||
</div>
|
||||
<div>
|
||||
{t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })}
|
||||
{t('tunnels.attempt', {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span> • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })}</span>
|
||||
<span> • {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import {TunnelObject} from "./TunnelObject.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type { SSHHost, TunnelConnection, TunnelStatus } from '../../../types/index.js';
|
||||
import type {SSHHost, TunnelConnection, TunnelStatus} from '../../../types/index.js';
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
hosts: SSHHost[];
|
||||
|
||||
@@ -5,10 +5,10 @@ import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx"
|
||||
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"
|
||||
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"
|
||||
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx";
|
||||
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { getUserInfo, getCookie, setCookie } from "@/ui/main-axios.ts";
|
||||
import {AdminSettings} from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
||||
import {UserProfile} from "@/ui/Desktop/User/UserProfile.tsx";
|
||||
import {Toaster} from "@/components/ui/sonner.tsx";
|
||||
import {getUserInfo, getCookie} from "@/ui/main-axios.ts";
|
||||
|
||||
function AppContent() {
|
||||
const [view, setView] = useState<string>("homepage")
|
||||
@@ -92,13 +92,13 @@ function AppContent() {
|
||||
transparent 100%
|
||||
)`,
|
||||
backgroundSize: '80px 80px'
|
||||
}} />
|
||||
}}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<Homepage
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
@@ -117,13 +117,13 @@ function AppContent() {
|
||||
>
|
||||
{showTerminalView && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<AppView isTopbarOpen={isTopbarOpen} />
|
||||
<AppView isTopbarOpen={isTopbarOpen}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHome && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<Homepage
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
@@ -135,26 +135,26 @@ function AppContent() {
|
||||
|
||||
{showSshManager && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdmin && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProfile && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-auto">
|
||||
<UserProfile isTopbarOpen={isTopbarOpen} />
|
||||
<UserProfile isTopbarOpen={isTopbarOpen}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
<Toaster
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
@@ -168,7 +168,7 @@ function AppContent() {
|
||||
function DesktopApp() {
|
||||
return (
|
||||
<TabProvider>
|
||||
<AppContent />
|
||||
<AppContent/>
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
|
||||
216
src/ui/Desktop/Electron Only/ServerConfig.tsx
Normal file
216
src/ui/Desktop/Electron Only/ServerConfig.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Label} from '@/components/ui/label.tsx';
|
||||
import {Alert, AlertTitle, AlertDescription} from '@/components/ui/alert.tsx';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {getServerConfig, saveServerConfig, testServerConnection, type ServerConfig} from '@/ui/main-axios.ts';
|
||||
import {CheckCircle, XCircle, Server, Wifi} from 'lucide-react';
|
||||
|
||||
interface ServerConfigProps {
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
onCancel?: () => void;
|
||||
isFirstTime?: boolean;
|
||||
}
|
||||
|
||||
export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}: ServerConfigProps) {
|
||||
const {t} = useTranslation();
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
}, []);
|
||||
|
||||
const loadServerConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
if (config?.serverUrl) {
|
||||
setServerUrl(config.serverUrl);
|
||||
setConnectionStatus('success');
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t('serverConfig.enterServerUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
|
||||
const result = await testServerConnection(normalizedUrl);
|
||||
|
||||
if (result.success) {
|
||||
setConnectionStatus('success');
|
||||
} else {
|
||||
setConnectionStatus('error');
|
||||
setError(result.error || t('serverConfig.connectionFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('error');
|
||||
setError(t('serverConfig.connectionError'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t('serverConfig.enterServerUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionStatus !== 'success') {
|
||||
setError(t('serverConfig.testConnectionFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
serverUrl: normalizedUrl,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
const success = await saveServerConfig(config);
|
||||
|
||||
if (success) {
|
||||
onServerConfigured(normalizedUrl);
|
||||
} else {
|
||||
setError(t('serverConfig.saveFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
setError(t('serverConfig.saveError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (value: string) => {
|
||||
setServerUrl(value);
|
||||
setConnectionStatus('unknown');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Server className="w-6 h-6 text-primary"/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t('serverConfig.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t('serverConfig.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">{t('serverConfig.serverUrl')}</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:8081 or https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className="flex-1 h-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !serverUrl.trim() || loading}
|
||||
className="w-10 h-10 p-0 flex items-center justify-center"
|
||||
>
|
||||
{testing ? (
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
|
||||
) : (
|
||||
<Wifi className="w-4 h-4"/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectionStatus !== 'unknown' && (
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{connectionStatus === 'success' ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500"/>
|
||||
<span className="text-green-600">{t('serverConfig.connected')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4 text-red-500"/>
|
||||
<span className="text-red-600">{t('serverConfig.disconnected')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{onCancel && !isFirstTime && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || testing || connectionStatus !== 'success'}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>
|
||||
<span>{t('serverConfig.saving')}</span>
|
||||
</div>
|
||||
) : (
|
||||
t('serverConfig.saveConfig')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t('serverConfig.helpText')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { Input } from '@/components/ui/input.tsx';
|
||||
import { Label } from '@/components/ui/label.tsx';
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert.tsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getServerConfig, saveServerConfig, testServerConnection, type ServerConfig } from '@/ui/main-axios.ts';
|
||||
import { CheckCircle, XCircle, Server, Wifi } from 'lucide-react';
|
||||
|
||||
interface ServerConfigProps {
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
onCancel?: () => void;
|
||||
isFirstTime?: boolean;
|
||||
}
|
||||
|
||||
export function ServerConfig({ onServerConfigured, onCancel, isFirstTime = false }: ServerConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
}, []);
|
||||
|
||||
const loadServerConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
if (config?.serverUrl) {
|
||||
setServerUrl(config.serverUrl);
|
||||
setConnectionStatus('success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load server config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t('serverConfig.enterServerUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Normalize URL
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
|
||||
const result = await testServerConnection(normalizedUrl);
|
||||
|
||||
if (result.success) {
|
||||
setConnectionStatus('success');
|
||||
} else {
|
||||
setConnectionStatus('error');
|
||||
setError(result.error || t('serverConfig.connectionFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('error');
|
||||
setError(t('serverConfig.connectionError'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t('serverConfig.enterServerUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionStatus !== 'success') {
|
||||
setError(t('serverConfig.testConnectionFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Normalize URL
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
serverUrl: normalizedUrl,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
const success = await saveServerConfig(config);
|
||||
|
||||
if (success) {
|
||||
onServerConfigured(normalizedUrl);
|
||||
} else {
|
||||
setError(t('serverConfig.saveFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
setError(t('serverConfig.saveError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (value: string) => {
|
||||
setServerUrl(value);
|
||||
setConnectionStatus('unknown');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Server className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t('serverConfig.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t('serverConfig.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">{t('serverConfig.serverUrl')}</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:8081 or https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className="flex-1 h-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !serverUrl.trim() || loading}
|
||||
className="w-10 h-10 p-0 flex items-center justify-center"
|
||||
>
|
||||
{testing ? (
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Wifi className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectionStatus !== 'unknown' && (
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{connectionStatus === 'success' ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-green-600">{t('serverConfig.connected')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-red-600">{t('serverConfig.disconnected')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{onCancel && !isFirstTime && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || testing || connectionStatus !== 'success'}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>{t('serverConfig.saving')}</span>
|
||||
</div>
|
||||
) : (
|
||||
t('serverConfig.saveConfig')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t('serverConfig.helpText')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
|
||||
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
|
||||
import {HomepageAlertManager} from "@/ui/Desktop/Homepage/HomepageAlertManager.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { getUserInfo, getDatabaseHealth, setCookie, getCookie } from "@/ui/main-axios.ts";
|
||||
import {getUserInfo, getDatabaseHealth, getCookie} from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface HomepageProps {
|
||||
@@ -15,22 +14,19 @@ interface HomepageProps {
|
||||
}
|
||||
|
||||
export function Homepage({
|
||||
onSelectView,
|
||||
isAuthenticated,
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen
|
||||
}: HomepageProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
|
||||
// Calculate margins based on topbar state (same logic as AppView.tsx)
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = 26; // Assuming sidebar is collapsed for homepage
|
||||
const leftMarginPx = 26;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -83,7 +79,7 @@ export function Homepage({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Button} from "@/components/ui/button.tsx";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type { TermixAlert } from '../../../types/index.js';
|
||||
import type {TermixAlert} from '../../../types/index.js';
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: TermixAlert;
|
||||
@@ -56,7 +56,7 @@ const getTypeBadgeVariant = (type?: string) => {
|
||||
|
||||
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
|
||||
|
||||
if (!alert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||
import {getUserAlerts, dismissAlert} from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type { TermixAlert } from '../../../types/index.js';
|
||||
import type {TermixAlert} from '../../../types/index.js';
|
||||
|
||||
interface AlertManagerProps {
|
||||
userId: string | null;
|
||||
@@ -49,7 +49,6 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user alerts:', err);
|
||||
const {toast} = await import('sonner');
|
||||
toast.error(t('homepage.failedToLoadAlerts'));
|
||||
setError(t('homepage.failedToLoadAlerts'));
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import {Eye, EyeOff} from "lucide-react";
|
||||
import {cn} from "@/lib/utils.ts";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
@@ -24,9 +23,8 @@ import {
|
||||
getCookie,
|
||||
getServerConfig,
|
||||
isElectron,
|
||||
type ServerConfig
|
||||
} from "../../main-axios.ts";
|
||||
import {ServerConfig as ServerConfigComponent} from "@/ui/Desktop/ElectronOnly/ServerConfig.tsx";
|
||||
import {ServerConfig as ServerConfigComponent} from "@/ui/Desktop/Electron Only/ServerConfig.tsx";
|
||||
|
||||
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||
setLoggedIn: (loggedIn: boolean) => void;
|
||||
@@ -61,14 +59,14 @@ export function HomepageAuth({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [visibility, setVisibility] = useState({
|
||||
password: false,
|
||||
signupConfirm: false,
|
||||
resetNew: false,
|
||||
resetConfirm: false
|
||||
});
|
||||
password: false,
|
||||
signupConfirm: false,
|
||||
resetNew: false,
|
||||
resetConfirm: false
|
||||
});
|
||||
const toggleVisibility = (field: keyof typeof visibility) => {
|
||||
setVisibility(prev => ({ ...prev, [field]: !prev[field] }));
|
||||
};
|
||||
setVisibility(prev => ({...prev, [field]: !prev[field]}));
|
||||
};
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||
@@ -83,7 +81,7 @@ export function HomepageAuth({
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetSuccess, setResetSuccess] = useState(false);
|
||||
|
||||
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpTempToken, setTotpTempToken] = useState("");
|
||||
@@ -159,23 +157,23 @@ export function HomepageAuth({
|
||||
await registerUser(localUsername, password);
|
||||
res = await loginUser(localUsername, password);
|
||||
}
|
||||
|
||||
|
||||
if (res.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpTempToken(res.temp_token);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error(t('errors.noTokenReceived'));
|
||||
}
|
||||
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
[meRes] = await Promise.all([
|
||||
getUserInfo(),
|
||||
]);
|
||||
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -300,17 +298,17 @@ export function HomepageAuth({
|
||||
|
||||
setError(null);
|
||||
setTotpLoading(true);
|
||||
|
||||
|
||||
try {
|
||||
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
||||
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error(t('errors.noTokenReceived'));
|
||||
}
|
||||
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
const meRes = await getUserInfo();
|
||||
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -408,58 +406,51 @@ export function HomepageAuth({
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Check if we need to show server config for Electron
|
||||
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(null);
|
||||
const [currentServerUrl, setCurrentServerUrl] = useState<string>('');
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const checkServerConfig = async () => {
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
console.log('Desktop HomepageAuth - Server config check:', config);
|
||||
setCurrentServerUrl(config?.serverUrl || '');
|
||||
setShowServerConfig(!config || !config.serverUrl);
|
||||
} catch (error) {
|
||||
console.log('Desktop HomepageAuth - No server config found, showing config screen');
|
||||
setShowServerConfig(true);
|
||||
}
|
||||
} else {
|
||||
setShowServerConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
checkServerConfig();
|
||||
}, []);
|
||||
|
||||
|
||||
if (showServerConfig === null) {
|
||||
// Still checking
|
||||
return (
|
||||
<div
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (showServerConfig) {
|
||||
console.log('Desktop HomepageAuth - SHOWING SERVER CONFIG SCREEN');
|
||||
return (
|
||||
<div
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
<ServerConfigComponent
|
||||
<ServerConfigComponent
|
||||
onServerConfigured={() => {
|
||||
console.log('Server configured, reloading page');
|
||||
window.location.reload();
|
||||
}}
|
||||
onCancel={() => {
|
||||
console.log('Cancelled server config, going back to login');
|
||||
setShowServerConfig(false);
|
||||
}}
|
||||
isFirstTime={!currentServerUrl}
|
||||
@@ -509,7 +500,7 @@ export function HomepageAuth({
|
||||
<h2 className="text-xl font-bold mb-1">{t('auth.twoFactorAuth')}</h2>
|
||||
<p className="text-muted-foreground">{t('auth.enterCode')}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="totp-code">{t('auth.verifyCode')}</Label>
|
||||
<Input
|
||||
@@ -527,7 +518,7 @@ export function HomepageAuth({
|
||||
{t('auth.backupCode')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
@@ -536,7 +527,7 @@ export function HomepageAuth({
|
||||
>
|
||||
{totpLoading ? Spinner : t('auth.verifyCode')}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -553,7 +544,7 @@ export function HomepageAuth({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
@@ -758,29 +749,30 @@ export function HomepageAuth({
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">{t('auth.newPassword')}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor="new-password">{t('auth.newPassword')}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
@@ -823,26 +815,26 @@ export function HomepageAuth({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t('common.password')}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
<Label htmlFor="password">{t('common.password')}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}>
|
||||
@@ -863,13 +855,13 @@ export function HomepageAuth({
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">{t('common.language')}</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
<LanguageSwitcher/>
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||
import {getReleasesRSS, getVersionInfo} from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||
@@ -90,7 +89,8 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[400px] h-[600px] flex flex-col border-2 border-dark-border rounded-lg bg-dark-bg p-4 shadow-lg">
|
||||
<div
|
||||
className="w-[400px] h-[600px] flex flex-col border-2 border-dark-border rounded-lg bg-dark-bg p-4 shadow-lg">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-3 text-white">{t('common.updatesAndReleases')}</h3>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
<Alert className="bg-dark-bg-darker border-dark-border text-white">
|
||||
<AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle>
|
||||
<AlertDescription className="text-gray-300">
|
||||
{t('common.newVersionAvailable', { version: versionInfo.version })}
|
||||
{t('common.newVersionAvailable', {version: versionInfo.version})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -212,7 +212,11 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||
if (allSplitScreenTab.length === 0) return null;
|
||||
|
||||
const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: 'var(--color-dark-border)'} as React.CSSProperties;
|
||||
const handleStyle = {
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 12,
|
||||
background: 'var(--color-dark-border)'
|
||||
} as React.CSSProperties;
|
||||
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
|
||||
|
||||
if (layoutTabs.length === 2) {
|
||||
@@ -226,7 +230,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} className="h-full w-full flex flex-col bg-transparent relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
@@ -235,7 +240,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} className="h-full w-full flex flex-col bg-transparent relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
@@ -260,7 +266,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
@@ -269,7 +276,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
@@ -283,7 +291,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(c.id)] = el;
|
||||
}} className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{c.title}</div>
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{c.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
@@ -305,7 +314,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
@@ -314,7 +324,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
@@ -332,7 +343,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(c.id)] = el;
|
||||
}} className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{c.title}</div>
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{c.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
@@ -341,7 +353,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(d.id)] = el;
|
||||
}} className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{d.title}</div>
|
||||
<div
|
||||
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{d.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -356,7 +369,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
|
||||
const isFileManager = currentTabData?.type === 'file_manager';
|
||||
const isSplitScreen = allSplitScreenTab.length > 0;
|
||||
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
@@ -35,7 +35,7 @@ interface FolderCardProps {
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
|
||||
export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactElement {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Server, Terminal} from "lucide-react";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||
import type { HostProps } from '../../../../types/index.js';
|
||||
import type {HostProps} from '../../../../types/index.js';
|
||||
|
||||
export function Host({host}: HostProps): React.ReactElement {
|
||||
const {addTab} = useTabs();
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, {useState} from 'react';
|
||||
import {
|
||||
ChevronUp, User2, HardDrive, Menu, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {getCookie, setCookie, isElectron} from "@/ui/main-axios.ts";
|
||||
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import {FolderCard} from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
|
||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { deleteAccount } from "@/ui/main-axios.ts";
|
||||
import {deleteAccount} from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -68,12 +68,11 @@ function handleLogout() {
|
||||
} else {
|
||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
}
|
||||
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function LeftSidebar({
|
||||
onSelectView,
|
||||
getView,
|
||||
@@ -82,8 +81,8 @@ export function LeftSidebar({
|
||||
username,
|
||||
children,
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {t} = useTranslation();
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState("");
|
||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||
@@ -128,7 +127,6 @@ export function LeftSidebar({
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
|
||||
|
||||
const fetchHosts = React.useCallback(async () => {
|
||||
try {
|
||||
const newHosts = await getSSHHosts();
|
||||
@@ -179,8 +177,7 @@ export function LeftSidebar({
|
||||
setTimeout(() => {
|
||||
setHosts(newHosts);
|
||||
prevHostsRef.current = newHosts;
|
||||
|
||||
// Update hostConfig in existing tabs
|
||||
|
||||
newHosts.forEach(newHost => {
|
||||
updateHostConfig(newHost.id, newHost);
|
||||
});
|
||||
@@ -193,7 +190,7 @@ export function LeftSidebar({
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds
|
||||
const interval = setInterval(fetchHosts, 300000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHosts]);
|
||||
|
||||
@@ -302,7 +299,8 @@ export function LeftSidebar({
|
||||
<Separator className="p-0.25"/>
|
||||
<SidebarContent>
|
||||
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
||||
<Button className="m-2 flex flex-row font-semibold border-2 !border-dark-border" variant="outline"
|
||||
<Button className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
|
||||
variant="outline"
|
||||
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
|
||||
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}>
|
||||
<HardDrive strokeWidth="2.5"/>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function Tab({
|
||||
>
|
||||
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
|
||||
<FolderIcon className="mr-1 h-4 w-4"/> : isUserProfile ?
|
||||
<UserIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
||||
<UserIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
||||
{title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : isUserProfile ? t('nav.userProfile') : t('nav.terminal'))}
|
||||
</Button>
|
||||
{canSplit && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type { TabContextTab } from '../../../types/index.js';
|
||||
import type {TabContextTab} from '../../../types/index.js';
|
||||
|
||||
export type Tab = TabContextTab;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {
|
||||
ChevronDown,
|
||||
Home,
|
||||
@@ -16,31 +16,31 @@ import {
|
||||
Network as SshManagerIcon,
|
||||
User as UserIcon
|
||||
} from "lucide-react";
|
||||
import { useTabs, type Tab } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {useTabs, type Tab} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
export function TabDropdown(): React.ReactElement {
|
||||
const { tabs, currentTab, setCurrentTab } = useTabs();
|
||||
const { t } = useTranslation();
|
||||
const {tabs, currentTab, setCurrentTab} = useTabs();
|
||||
const {t} = useTranslation();
|
||||
|
||||
const getTabIcon = (tabType: Tab['type']) => {
|
||||
switch (tabType) {
|
||||
case 'home':
|
||||
return <Home className="h-4 w-4" />;
|
||||
return <Home className="h-4 w-4"/>;
|
||||
case 'terminal':
|
||||
return <TerminalIcon className="h-4 w-4" />;
|
||||
return <TerminalIcon className="h-4 w-4"/>;
|
||||
case 'server':
|
||||
return <ServerIcon className="h-4 w-4" />;
|
||||
return <ServerIcon className="h-4 w-4"/>;
|
||||
case 'file_manager':
|
||||
return <FolderIcon className="h-4 w-4" />;
|
||||
return <FolderIcon className="h-4 w-4"/>;
|
||||
case 'user_profile':
|
||||
return <UserIcon className="h-4 w-4" />;
|
||||
return <UserIcon className="h-4 w-4"/>;
|
||||
case 'ssh_manager':
|
||||
return <SshManagerIcon className="h-4 w-4" />;
|
||||
return <SshManagerIcon className="h-4 w-4"/>;
|
||||
case 'admin':
|
||||
return <AdminIcon className="h-4 w-4" />;
|
||||
return <AdminIcon className="h-4 w-4"/>;
|
||||
default:
|
||||
return <TerminalIcon className="h-4 w-4" />;
|
||||
return <TerminalIcon className="h-4 w-4"/>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,7 +68,6 @@ export function TabDropdown(): React.ReactElement {
|
||||
setCurrentTab(tabId);
|
||||
};
|
||||
|
||||
// If only one tab (home), don't show dropdown
|
||||
if (tabs.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -79,9 +78,9 @@ export function TabDropdown(): React.ReactElement {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px] border-dark-border"
|
||||
title={t('nav.tabNavigation', { defaultValue: 'Tab Navigation' })}
|
||||
title={t('nav.tabNavigation', {defaultValue: 'Tab Navigation'})}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-4 w-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -105,7 +104,7 @@ export function TabDropdown(): React.ReactElement {
|
||||
{getTabDisplayTitle(tab)}
|
||||
</span>
|
||||
{isActive && (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"/>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -255,8 +255,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||
<TabDropdown />
|
||||
|
||||
<TabDropdown/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px]"
|
||||
@@ -348,7 +348,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
{isRecording && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">{t('sshTools.selectTerminals')}</label>
|
||||
<label
|
||||
className="text-sm font-medium text-white">{t('sshTools.selectTerminals')}</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
|
||||
{terminalTabs.map(tab => (
|
||||
<Button
|
||||
@@ -370,7 +371,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">{t('sshTools.typeCommands')}</label>
|
||||
<label
|
||||
className="text-sm font-medium text-white">{t('sshTools.typeCommands')}</label>
|
||||
<Input
|
||||
id="ssh-tools-input"
|
||||
placeholder={t('placeholders.typeHere')}
|
||||
@@ -381,7 +383,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })}
|
||||
{t('sshTools.commandsWillBeSent', {count: selectedTabIds.length})}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,44 +1,42 @@
|
||||
// Language switcher component for changing UI language
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx';
|
||||
import { Globe } from 'lucide-react';
|
||||
import {Globe} from 'lucide-react';
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'zh', name: 'Chinese', nativeName: '中文' },
|
||||
{code: 'en', name: 'English', nativeName: 'English'},
|
||||
{code: 'zh', name: 'Chinese', nativeName: '中文'},
|
||||
];
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const {i18n, t} = useTranslation();
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
i18n.changeLanguage(value);
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('i18nextLng', value);
|
||||
};
|
||||
const handleLanguageChange = (value: string) => {
|
||||
i18n.changeLanguage(value);
|
||||
localStorage.setItem('i18nextLng', value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 relative z-[99999]">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<Select value={i18n.language} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder={t('placeholders.language')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.nativeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 relative z-[99999]">
|
||||
<Globe className="h-4 w-4 text-muted-foreground"/>
|
||||
<Select value={i18n.language} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder={t('placeholders.language')}/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.nativeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
|
||||
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, {useState} from "react";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {PasswordInput} from "@/components/ui/password-input.tsx";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {Shield, Copy, Download, AlertCircle, CheckCircle2} from "lucide-react";
|
||||
import {setupTOTP, enableTOTP, disableTOTP, generateBackupCodes} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface TOTPSetupProps {
|
||||
isEnabled: boolean;
|
||||
onStatusChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) {
|
||||
export function TOTPSetup({isEnabled: initialEnabled, onStatusChange}: TOTPSetupProps) {
|
||||
const {t} = useTranslation();
|
||||
const [isEnabled, setIsEnabled] = useState(initialEnabled);
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
@@ -109,8 +109,8 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
`Generated: ${new Date().toISOString()}\n\n` +
|
||||
`Keep these codes in a safe place. Each code can only be used once.\n\n` +
|
||||
backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
|
||||
const blob = new Blob([content], {type: 'text/plain'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -133,7 +133,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<Shield className="w-5 h-5"/>
|
||||
{t('auth.twoFactorTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -142,7 +142,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<CheckCircle2 className="h-4 w-4"/>
|
||||
<AlertTitle>{t('common.enabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('auth.twoFactorActive')}
|
||||
@@ -154,16 +154,16 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
<TabsTrigger value="disable">{t('auth.disable2FA')}</TabsTrigger>
|
||||
<TabsTrigger value="backup">{t('auth.backupCodes')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
<TabsContent value="disable" className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>{t('common.warning')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('auth.disableTwoFactorWarning')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">{t('auth.passwordOrTotpCode')}</Label>
|
||||
<PasswordInput
|
||||
@@ -182,7 +182,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDisable}
|
||||
@@ -191,12 +191,12 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
{t('auth.disableTwoFactor')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="backup" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('auth.generateNewBackupCodesText')}
|
||||
</p>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-password">{t('auth.passwordOrTotpCode')}</Label>
|
||||
<PasswordInput
|
||||
@@ -215,14 +215,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateNewBackupCodes}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
{t('auth.generateNewBackupCodes')}
|
||||
</Button>
|
||||
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -232,7 +232,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Download className="w-4 h-4 mr-2"/>
|
||||
{t('auth.download')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -248,7 +248,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
@@ -269,9 +269,9 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<img src={qrCode} alt="TOTP QR Code" className="w-64 h-64" />
|
||||
<img src={qrCode} alt="TOTP QR Code" className="w-64 h-64"/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('auth.manualEntryCode')}</Label>
|
||||
<div className="flex gap-2">
|
||||
@@ -285,14 +285,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(secret, "Secret key")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<Copy className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('auth.cannotScanQRText')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Button onClick={() => setSetupStep("verify")} className="w-full">
|
||||
{t('auth.nextVerifyCode')}
|
||||
</Button>
|
||||
@@ -323,15 +323,15 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -364,13 +364,13 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>{t('common.important')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('auth.importantBackupCodesText')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
@@ -379,7 +379,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Download className="w-4 h-4 mr-2"/>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
@@ -392,7 +392,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Button onClick={handleComplete} className="w-full">
|
||||
{t('auth.completeSetup')}
|
||||
</Button>
|
||||
@@ -405,14 +405,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<Shield className="w-5 h-5"/>
|
||||
{t('auth.twoFactorTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className="space-y-2">
|
||||
<p>{t('auth.addExtraSecurityLayer')}.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open('https://docs.termix.site/totp', '_blank')}
|
||||
>
|
||||
@@ -422,20 +422,20 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>{t('common.notEnabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('auth.notEnabledText')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
<Button onClick={handleSetupStart} disabled={loading} className="w-full">
|
||||
{loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')}
|
||||
</Button>
|
||||
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
@@ -10,13 +7,11 @@ import {User, Shield, Key, AlertCircle} from "lucide-react";
|
||||
import {TOTPSetup} from "@/ui/Desktop/User/TOTPSetup.tsx";
|
||||
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||
import {getVersionInfo} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {PasswordReset} from "@/ui/Desktop/User/PasswordReset.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {LanguageSwitcher} from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
|
||||
|
||||
interface UserProfileProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
@@ -45,7 +40,6 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
const info = await getVersionInfo();
|
||||
setVersionInfo({version: info.localVersion});
|
||||
} catch (err) {
|
||||
console.error("Failed to load version info", err);
|
||||
const {toast} = await import('sonner');
|
||||
toast.error(t('user.failedToLoadVersionInfo'));
|
||||
}
|
||||
@@ -88,7 +82,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={wrapperStyle} className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden">
|
||||
<div style={wrapperStyle}
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t('nav.userProfile')}</h1>
|
||||
@@ -104,7 +99,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
|
||||
if (error || !userInfo) {
|
||||
return (
|
||||
<div style={wrapperStyle} className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden">
|
||||
<div style={wrapperStyle}
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t('nav.userProfile')}</h1>
|
||||
@@ -114,7 +110,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
<Alert variant="destructive" className="bg-red-900/20 border-red-500/50">
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle className="text-red-400">{t('common.error')}</AlertTitle>
|
||||
<AlertDescription className="text-red-300">{error || t('errors.loadFailed')}</AlertDescription>
|
||||
<AlertDescription
|
||||
className="text-red-300">{error || t('errors.loadFailed')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +120,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden">
|
||||
<div style={wrapperStyle}
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t('nav.userProfile')}</h1>
|
||||
@@ -133,12 +131,14 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
<div className="px-6 py-4 overflow-auto flex-1">
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger value="profile" className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button">
|
||||
<TabsTrigger value="profile"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button">
|
||||
<User className="w-4 h-4"/>
|
||||
{t('nav.userProfile')}
|
||||
</TabsTrigger>
|
||||
{!userInfo.is_oidc && (
|
||||
<TabsTrigger value="security" className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button">
|
||||
<TabsTrigger value="security"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button">
|
||||
<Shield className="w-4 h-4"/>
|
||||
{t('profile.security')}
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Server, Terminal} from "lucide-react";
|
||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx";
|
||||
import type { HostProps } from '../../../../../types/index.js';
|
||||
import type {HostProps} from '../../../../../types/index.js';
|
||||
|
||||
export function Host({host, onHostConnect}: HostProps): React.ReactElement {
|
||||
const {addTab} = useTabs();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type { TabContextTab } from '../../../../types/index.js';
|
||||
import type {TabContextTab} from '../../../../types/index.js';
|
||||
|
||||
export type Tab = TabContextTab;
|
||||
|
||||
|
||||
@@ -185,7 +185,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
textarea.blur();
|
||||
}
|
||||
|
||||
terminal.focus = () => {};
|
||||
terminal.focus = () => {
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
@@ -221,11 +222,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
? 'ws://localhost:8082'
|
||||
: isElectron()
|
||||
? (() => {
|
||||
// Get configured server URL from window object (set by main-axios)
|
||||
const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081';
|
||||
// Convert HTTP/HTTPS to WS/WSS and use nginx reverse proxy path
|
||||
const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://';
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, ''); // Keep the port
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, '');
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
@@ -89,8 +89,6 @@ export function TerminalKeyboard({onSendInput, onLayoutChange}: TerminalKeyboard
|
||||
navigator.vibrate(20);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Vibration failed:", e);
|
||||
// Don't show toast for vibration failure as it's not critical
|
||||
}
|
||||
|
||||
onSendInput(input);
|
||||
|
||||
@@ -737,17 +737,17 @@ export function HomepageAuth({
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t('common.password')}</Label>
|
||||
<PasswordInput id="password" required className="h-11 text-base"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
|
||||
<PasswordInput id="signup-confirm-password" required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useRef, FC, useState, useEffect} from "react";
|
||||
import React, {useState, useEffect} from "react";
|
||||
import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
|
||||
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
|
||||
import {BottomNavbar} from "@/ui/Mobile/Navigation/BottomNavbar.tsx";
|
||||
@@ -128,7 +128,8 @@ const AppContent: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-dark-bg-darkest overflow-y-hidden overflow-x-hidden relative">
|
||||
<div
|
||||
className="h-screen w-screen flex flex-col bg-dark-bg-darkest overflow-y-hidden overflow-x-hidden relative">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
|
||||
@@ -5,7 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Server, Terminal} from "lucide-react";
|
||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
||||
import type { HostProps } from '../../../../types/index.js';
|
||||
import type {HostProps} from '../../../../types/index.js';
|
||||
|
||||
export function Host({host, onHostConnect}: HostProps): React.ReactElement {
|
||||
const {addTab} = useTabs();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type { TabContextTab } from '../../../types/index.js';
|
||||
import type {TabContextTab} from '../../../types/index.js';
|
||||
|
||||
export type Tab = TabContextTab;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHHostData,
|
||||
TunnelConfig,
|
||||
TunnelStatus,
|
||||
import axios, {AxiosError, type AxiosInstance} from 'axios';
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHHostData,
|
||||
TunnelConfig,
|
||||
TunnelStatus,
|
||||
Credential,
|
||||
CredentialData,
|
||||
HostInfo,
|
||||
@@ -11,7 +11,16 @@ import type {
|
||||
FileManagerFile,
|
||||
FileManagerShortcut
|
||||
} from '../types/index.js';
|
||||
import { apiLogger, authLogger, sshLogger, tunnelLogger, fileLogger, statsLogger, systemLogger, type LogContext } from '../lib/frontend-logger.js';
|
||||
import {
|
||||
apiLogger,
|
||||
authLogger,
|
||||
sshLogger,
|
||||
tunnelLogger,
|
||||
fileLogger,
|
||||
statsLogger,
|
||||
systemLogger,
|
||||
type LogContext
|
||||
} from '../lib/frontend-logger.js';
|
||||
|
||||
interface FileManagerOperation {
|
||||
name: string;
|
||||
@@ -119,16 +128,14 @@ export function getCookie(name: string): string | undefined {
|
||||
function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosInstance {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Request interceptor with enhanced logging
|
||||
instance.interceptors.request.use((config) => {
|
||||
const startTime = performance.now();
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Store timing and request ID for response logging
|
||||
|
||||
(config as any).startTime = startTime;
|
||||
(config as any).requestId = requestId;
|
||||
|
||||
@@ -144,10 +151,8 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
|
||||
operation: 'request_start'
|
||||
};
|
||||
|
||||
// Get the appropriate logger for this service
|
||||
const logger = getLoggerForService(serviceName);
|
||||
|
||||
// Log request start with grouping
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.requestStart(method, fullUrl, context);
|
||||
}
|
||||
@@ -158,7 +163,6 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
|
||||
authLogger.warn('No JWT token found, request will be unauthenticated', context);
|
||||
}
|
||||
|
||||
// Add Electron-specific headers for OIDC and other backend detection
|
||||
if (isElectron) {
|
||||
config.headers['X-Electron-App'] = 'true';
|
||||
config.headers['User-Agent'] = 'Termix-Electron/1.6.0';
|
||||
@@ -167,7 +171,6 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor with comprehensive logging
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
const endTime = performance.now();
|
||||
@@ -189,15 +192,12 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
|
||||
operation: 'request_success'
|
||||
};
|
||||
|
||||
// Get the appropriate logger for this service
|
||||
const logger = getLoggerForService(serviceName);
|
||||
|
||||
// Log successful requests in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.requestSuccess(method, fullUrl, response.status, responseTime, context);
|
||||
}
|
||||
|
||||
// Performance logging for slow requests
|
||||
if (responseTime > 3000) {
|
||||
logger.warn(`🐌 Slow request: ${responseTime}ms`, context);
|
||||
}
|
||||
@@ -228,10 +228,8 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
|
||||
operation: 'request_error'
|
||||
};
|
||||
|
||||
// Get the appropriate logger for this service
|
||||
const logger = getLoggerForService(serviceName);
|
||||
|
||||
// Log errors with appropriate method based on error type
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (status === 401) {
|
||||
logger.authError(method, fullUrl, context);
|
||||
@@ -242,7 +240,6 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auth token clearing
|
||||
if (status === 401) {
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem('jwt');
|
||||
@@ -264,8 +261,8 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
|
||||
// ============================================================================
|
||||
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
let apiHost = import.meta.env.VITE_API_HOST || 'localhost';
|
||||
let apiPort = 8081;
|
||||
@@ -275,7 +272,6 @@ if (isElectron) {
|
||||
apiPort = 8081;
|
||||
}
|
||||
|
||||
// Server configuration management for Electron
|
||||
export interface ServerConfig {
|
||||
serverUrl: string;
|
||||
lastUpdated: string;
|
||||
@@ -283,7 +279,7 @@ export interface ServerConfig {
|
||||
|
||||
export async function getServerConfig(): Promise<ServerConfig | null> {
|
||||
if (!isElectron) return null;
|
||||
|
||||
|
||||
try {
|
||||
const result = await (window as any).electronAPI?.invoke('get-server-config');
|
||||
return result;
|
||||
@@ -295,7 +291,7 @@ export async function getServerConfig(): Promise<ServerConfig | null> {
|
||||
|
||||
export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
|
||||
if (!isElectron) return false;
|
||||
|
||||
|
||||
try {
|
||||
const result = await (window as any).electronAPI?.invoke('save-server-config', config);
|
||||
if (result?.success) {
|
||||
@@ -311,18 +307,17 @@ export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function testServerConnection(serverUrl: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!isElectron) return { success: false, error: 'Not in Electron environment' };
|
||||
|
||||
if (!isElectron) return {success: false, error: 'Not in Electron environment'};
|
||||
|
||||
try {
|
||||
const result = await (window as any).electronAPI?.invoke('test-server-connection', serverUrl);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to test server connection:', error);
|
||||
return { success: false, error: 'Connection test failed' };
|
||||
return {success: false, error: 'Connection test failed'};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize server configuration on load
|
||||
if (isElectron) {
|
||||
getServerConfig().then(config => {
|
||||
if (config?.serverUrl) {
|
||||
@@ -335,12 +330,9 @@ if (isElectron) {
|
||||
function getApiUrl(path: string, defaultPort: number): string {
|
||||
if (isElectron()) {
|
||||
if (configuredServerUrl) {
|
||||
// In Electron with configured server, all requests go through nginx reverse proxy
|
||||
// Use the same base URL for all services (nginx routes to correct backend port)
|
||||
const baseUrl = configuredServerUrl.replace(/\/$/, '');
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
// In Electron without configured server, return a placeholder that will cause requests to fail gracefully
|
||||
return 'http://no-server-configured';
|
||||
} else if (isDev) {
|
||||
return `http://${apiHost}:${defaultPort}${path}`;
|
||||
@@ -349,7 +341,6 @@ function getApiUrl(path: string, defaultPort: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-port backend architecture (original design)
|
||||
// SSH Host Management API (port 8081)
|
||||
export let sshHostApi = createApiInstance(
|
||||
getApiUrl('/ssh', 8081),
|
||||
@@ -362,7 +353,7 @@ export let tunnelApi = createApiInstance(
|
||||
'TUNNEL'
|
||||
);
|
||||
|
||||
// File Manager Operations API (port 8084) - SSH file operations
|
||||
// File Manager Operations API (port 8084)
|
||||
export let fileManagerApi = createApiInstance(
|
||||
getApiUrl('/ssh/file_manager', 8084),
|
||||
'FILE_MANAGER'
|
||||
@@ -374,43 +365,31 @@ export let statsApi = createApiInstance(
|
||||
'STATS'
|
||||
);
|
||||
|
||||
// Authentication API (port 8081) - includes users, alerts, version, releases
|
||||
// Authentication API (port 8081)
|
||||
export let authApi = createApiInstance(
|
||||
getApiUrl('', 8081),
|
||||
'AUTH'
|
||||
);
|
||||
|
||||
// Function to update API instances with new server configuration
|
||||
function updateApiInstances() {
|
||||
systemLogger.info('Updating API instances with new server configuration', {
|
||||
operation: 'api_instance_update',
|
||||
configuredServerUrl
|
||||
systemLogger.info('Updating API instances with new server configuration', {
|
||||
operation: 'api_instance_update',
|
||||
configuredServerUrl
|
||||
});
|
||||
|
||||
|
||||
sshHostApi = createApiInstance(getApiUrl('/ssh', 8081), 'SSH_HOST');
|
||||
tunnelApi = createApiInstance(getApiUrl('/ssh', 8083), 'TUNNEL');
|
||||
fileManagerApi = createApiInstance(getApiUrl('/ssh/file_manager', 8084), 'FILE_MANAGER');
|
||||
statsApi = createApiInstance(getApiUrl('', 8085), 'STATS');
|
||||
authApi = createApiInstance(getApiUrl('', 8081), 'AUTH');
|
||||
|
||||
|
||||
// Make configuredServerUrl available globally for components that need it
|
||||
(window as any).configuredServerUrl = configuredServerUrl;
|
||||
|
||||
systemLogger.success('All API instances updated successfully', {
|
||||
operation: 'api_instance_update_complete',
|
||||
configuredServerUrl
|
||||
});
|
||||
}
|
||||
|
||||
// Function to update API instances with new port (for Electron) - kept for backward compatibility
|
||||
function updateApiPorts(port: number) {
|
||||
systemLogger.info('Updating API instances with new port', {
|
||||
operation: 'api_port_update',
|
||||
newPort: port
|
||||
systemLogger.success('All API instances updated successfully', {
|
||||
operation: 'api_instance_update_complete',
|
||||
configuredServerUrl
|
||||
});
|
||||
|
||||
apiPort = port;
|
||||
updateApiInstances();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -440,7 +419,7 @@ function handleApiError(error: unknown, operation: string): never {
|
||||
const code = error.response?.data?.code;
|
||||
const url = error.config?.url;
|
||||
const method = error.config?.method?.toUpperCase();
|
||||
|
||||
|
||||
const errorContext: LogContext = {
|
||||
...context,
|
||||
method,
|
||||
@@ -449,8 +428,7 @@ function handleApiError(error: unknown, operation: string): never {
|
||||
errorCode: code,
|
||||
errorMessage: message
|
||||
};
|
||||
|
||||
// Enhanced error logging with appropriate logger
|
||||
|
||||
if (status === 401) {
|
||||
authLogger.warn(`Auth failed: ${method} ${url} - ${message}`, errorContext);
|
||||
throw new ApiError('Authentication required. Please log in again.', 401, 'AUTH_REQUIRED');
|
||||
@@ -482,11 +460,11 @@ function handleApiError(error: unknown, operation: string): never {
|
||||
throw new ApiError(message || `Failed to ${operation}`, status, code);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
apiLogger.error(`Unexpected error during ${operation}: ${errorMessage}`, error, context);
|
||||
throw new ApiError(`Unexpected error during ${operation}: ${errorMessage}`, undefined, 'UNKNOWN_ERROR');
|
||||
@@ -540,12 +518,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
const formData = new FormData();
|
||||
formData.append('key', hostData.key);
|
||||
|
||||
const dataWithoutFile = { ...submitData };
|
||||
const dataWithoutFile = {...submitData};
|
||||
delete dataWithoutFile.key;
|
||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||
|
||||
const response = await sshHostApi.post('/db/host', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
headers: {'Content-Type': 'multipart/form-data'},
|
||||
});
|
||||
return response.data;
|
||||
} else {
|
||||
@@ -591,12 +569,12 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
||||
const formData = new FormData();
|
||||
formData.append('key', hostData.key);
|
||||
|
||||
const dataWithoutFile = { ...submitData };
|
||||
const dataWithoutFile = {...submitData};
|
||||
delete dataWithoutFile.key;
|
||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||
|
||||
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
headers: {'Content-Type': 'multipart/form-data'},
|
||||
});
|
||||
return response.data;
|
||||
} else {
|
||||
@@ -615,7 +593,7 @@ export async function bulkImportSSHHosts(hosts: SSHHostData[]): Promise<{
|
||||
errors: string[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.post('/bulk-import', { hosts });
|
||||
const response = await sshHostApi.post('/bulk-import', {hosts});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'bulk import SSH hosts');
|
||||
@@ -669,7 +647,7 @@ export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||
|
||||
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||
try {
|
||||
const response = await tunnelApi.post('/tunnel/disconnect', { tunnelName });
|
||||
const response = await tunnelApi.post('/tunnel/disconnect', {tunnelName});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'disconnect tunnel');
|
||||
@@ -678,7 +656,7 @@ export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||
|
||||
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
||||
try {
|
||||
const response = await tunnelApi.post('/tunnel/cancel', { tunnelName });
|
||||
const response = await tunnelApi.post('/tunnel/cancel', {tunnelName});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'cancel tunnel');
|
||||
@@ -709,7 +687,7 @@ export async function addFileManagerRecent(file: FileManagerOperation): Promise<
|
||||
|
||||
export async function removeFileManagerRecent(file: FileManagerOperation): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete('/file_manager/recent', { data: file });
|
||||
const response = await sshHostApi.delete('/file_manager/recent', {data: file});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'remove recent file');
|
||||
@@ -736,7 +714,7 @@ export async function addFileManagerPinned(file: FileManagerOperation): Promise<
|
||||
|
||||
export async function removeFileManagerPinned(file: FileManagerOperation): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete('/file_manager/pinned', { data: file });
|
||||
const response = await sshHostApi.delete('/file_manager/pinned', {data: file});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'remove pinned file');
|
||||
@@ -763,7 +741,7 @@ export async function addFileManagerShortcut(shortcut: FileManagerOperation): Pr
|
||||
|
||||
export async function removeFileManagerShortcut(shortcut: FileManagerOperation): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete('/file_manager/shortcuts', { data: shortcut });
|
||||
const response = await sshHostApi.delete('/file_manager/shortcuts', {data: shortcut});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'remove shortcut');
|
||||
@@ -799,7 +777,7 @@ export async function connectSSH(sessionId: string, config: {
|
||||
|
||||
export async function disconnectSSH(sessionId: string): Promise<any> {
|
||||
try {
|
||||
const response = await fileManagerApi.post('/ssh/disconnect', { sessionId });
|
||||
const response = await fileManagerApi.post('/ssh/disconnect', {sessionId});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'disconnect SSH');
|
||||
@@ -809,7 +787,7 @@ export async function disconnectSSH(sessionId: string): Promise<any> {
|
||||
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get('/ssh/status', {
|
||||
params: { sessionId }
|
||||
params: {sessionId}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -820,7 +798,7 @@ export async function getSSHStatus(sessionId: string): Promise<{ connected: bool
|
||||
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
|
||||
try {
|
||||
const response = await fileManagerApi.get('/ssh/listFiles', {
|
||||
params: { sessionId, path }
|
||||
params: {sessionId, path}
|
||||
});
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
@@ -831,7 +809,7 @@ export async function listSSHFiles(sessionId: string, path: string): Promise<any
|
||||
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get('/ssh/readFile', {
|
||||
params: { sessionId, path }
|
||||
params: {sessionId, path}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -975,7 +953,7 @@ export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
|
||||
|
||||
export async function registerUser(username: string, password: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/users/create', { username, password });
|
||||
const response = await authApi.post('/users/create', {username, password});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'register user');
|
||||
@@ -984,7 +962,7 @@ export async function registerUser(username: string, password: string): Promise<
|
||||
|
||||
export async function loginUser(username: string, password: string): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await authApi.post('/users/login', { username, password });
|
||||
const response = await authApi.post('/users/login', {username, password});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'login user');
|
||||
@@ -1015,7 +993,6 @@ export async function getOIDCConfig(): Promise<any> {
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.warn('Failed to fetch OIDC config:', error.response?.data?.error || error.message);
|
||||
// Don't show toast for OIDC config as it's optional
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1031,7 +1008,7 @@ export async function getUserCount(): Promise<UserCount> {
|
||||
|
||||
export async function initiatePasswordReset(username: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/users/initiate-reset', { username });
|
||||
const response = await authApi.post('/users/initiate-reset', {username});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'initiate password reset');
|
||||
@@ -1040,7 +1017,7 @@ export async function initiatePasswordReset(username: string): Promise<any> {
|
||||
|
||||
export async function verifyPasswordResetCode(username: string, resetCode: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/users/verify-reset-code', { username, resetCode });
|
||||
const response = await authApi.post('/users/verify-reset-code', {username, resetCode});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'verify reset code');
|
||||
@@ -1049,7 +1026,7 @@ export async function verifyPasswordResetCode(username: string, resetCode: strin
|
||||
|
||||
export async function completePasswordReset(username: string, tempToken: string, newPassword: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/users/complete-reset', { username, tempToken, newPassword });
|
||||
const response = await authApi.post('/users/complete-reset', {username, tempToken, newPassword});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'complete password reset');
|
||||
@@ -1080,7 +1057,7 @@ export async function getUserList(): Promise<{ users: UserInfo[] }> {
|
||||
|
||||
export async function makeUserAdmin(username: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/users/make-admin', { username });
|
||||
const response = await authApi.post('/users/make-admin', {username});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'make user admin');
|
||||
@@ -1089,7 +1066,7 @@ export async function makeUserAdmin(username: string): Promise<any> {
|
||||
|
||||
export async function removeAdminStatus(username: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/users/remove-admin', { username });
|
||||
const response = await authApi.post('/users/remove-admin', {username});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'remove admin status');
|
||||
@@ -1098,7 +1075,7 @@ export async function removeAdminStatus(username: string): Promise<any> {
|
||||
|
||||
export async function deleteUser(username: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.delete('/users/delete-user', { data: { username } });
|
||||
const response = await authApi.delete('/users/delete-user', {data: {username}});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'delete user');
|
||||
@@ -1107,7 +1084,7 @@ export async function deleteUser(username: string): Promise<any> {
|
||||
|
||||
export async function deleteAccount(password: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.delete('/users/delete-account', { data: { password } });
|
||||
const response = await authApi.delete('/users/delete-account', {data: {password}});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'delete account');
|
||||
@@ -1116,7 +1093,7 @@ export async function deleteAccount(password: string): Promise<any> {
|
||||
|
||||
export async function updateRegistrationAllowed(allowed: boolean): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.patch('/users/registration-allowed', { allowed });
|
||||
const response = await authApi.patch('/users/registration-allowed', {allowed});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'update registration allowed');
|
||||
@@ -1157,7 +1134,7 @@ export async function setupTOTP(): Promise<{ secret: string; qr_code: string }>
|
||||
|
||||
export async function enableTOTP(totp_code: string): Promise<{ message: string; backup_codes: string[] }> {
|
||||
try {
|
||||
const response = await authApi.post('/users/totp/enable', { totp_code });
|
||||
const response = await authApi.post('/users/totp/enable', {totp_code});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, 'enable TOTP');
|
||||
@@ -1167,7 +1144,7 @@ export async function enableTOTP(totp_code: string): Promise<{ message: string;
|
||||
|
||||
export async function disableTOTP(password?: string, totp_code?: string): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await authApi.post('/users/totp/disable', { password, totp_code });
|
||||
const response = await authApi.post('/users/totp/disable', {password, totp_code});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, 'disable TOTP');
|
||||
@@ -1177,7 +1154,7 @@ export async function disableTOTP(password?: string, totp_code?: string): Promis
|
||||
|
||||
export async function verifyTOTPLogin(temp_token: string, totp_code: string): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code });
|
||||
const response = await authApi.post('/users/totp/verify-login', {temp_token, totp_code});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, 'verify TOTP login');
|
||||
@@ -1187,7 +1164,7 @@ export async function verifyTOTPLogin(temp_token: string, totp_code: string): Pr
|
||||
|
||||
export async function generateBackupCodes(password?: string, totp_code?: string): Promise<{ backup_codes: string[] }> {
|
||||
try {
|
||||
const response = await authApi.post('/users/totp/backup-codes', { password, totp_code });
|
||||
const response = await authApi.post('/users/totp/backup-codes', {password, totp_code});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, 'generate backup codes');
|
||||
@@ -1206,7 +1183,7 @@ export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }>
|
||||
|
||||
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/alerts/dismiss', { userId, alertId });
|
||||
const response = await authApi.post('/alerts/dismiss', {userId, alertId});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'dismiss alert');
|
||||
@@ -1328,7 +1305,7 @@ export async function getSSHHostWithCredentials(hostId: number): Promise<any> {
|
||||
// Apply credential to SSH host
|
||||
export async function applyCredentialToHost(hostId: number, credentialId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.post(`/db/host/${hostId}/apply-credential`, { credentialId });
|
||||
const response = await sshHostApi.post(`/db/host/${hostId}/apply-credential`, {credentialId});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'apply credential to host');
|
||||
@@ -1348,7 +1325,7 @@ export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
||||
// Migrate host to managed credential
|
||||
export async function migrateHostToCredential(hostId: number, credentialName: string): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.post(`/db/host/${hostId}/migrate-to-credential`, { credentialName });
|
||||
const response = await sshHostApi.post(`/db/host/${hostId}/migrate-to-credential`, {credentialName});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'migrate host to credential');
|
||||
|
||||
Reference in New Issue
Block a user