Clean up frontend files and read me translations

This commit is contained in:
LukeGus
2025-09-12 00:57:08 -05:00
parent 4fdda82a30
commit ad05021fc5
63 changed files with 1478 additions and 1622 deletions

View File

@@ -119,7 +119,7 @@ reviews:
- Identify and fix potential null/undefined access errors - Identify and fix potential null/undefined access errors
- Fix improper event handling and memory leaks - Fix improper event handling and memory leaks
- Resolve improper state management and data flow issues - Resolve improper state management and data flow issues
- path: "**/backend/**/*.{ts,js}" - path: "**/backend/**/*.{ts,js}"
instructions: | instructions: |
Review backend code for Termix server management platform. Key considerations: Review backend code for Termix server management platform. Key considerations:
@@ -167,7 +167,7 @@ reviews:
- Implement proper health checks and status endpoints - Implement proper health checks and status endpoints
Highlight any security vulnerabilities, performance issues, or architectural deviations. Highlight any security vulnerabilities, performance issues, or architectural deviations.
- path: "**/components/**/*.{ts,tsx}" - path: "**/components/**/*.{ts,tsx}"
instructions: | instructions: |
Review UI components for Termix server management platform. Key considerations: Review UI components for Termix server management platform. Key considerations:
@@ -207,7 +207,7 @@ reviews:
- Use proper tunnel status and management UI - Use proper tunnel status and management UI
Highlight any UI/UX issues, accessibility problems, or performance concerns. Highlight any UI/UX issues, accessibility problems, or performance concerns.
- path: "**/types/**/*.{ts,js}" - path: "**/types/**/*.{ts,js}"
instructions: | instructions: |
Review type definitions for Termix server management platform. Key considerations: Review type definitions for Termix server management platform. Key considerations:
@@ -237,7 +237,7 @@ reviews:
- Use proper type assertions and casting - Use proper type assertions and casting
Highlight any type safety issues, missing types, or type inconsistencies. Highlight any type safety issues, missing types, or type inconsistencies.
- path: "**/hooks/**/*.{ts,tsx}" - path: "**/hooks/**/*.{ts,tsx}"
instructions: | instructions: |
Review custom hooks for Termix server management platform. Key considerations: Review custom hooks for Termix server management platform. Key considerations:
@@ -285,7 +285,7 @@ reviews:
- Fix improper error handling in custom hooks - Fix improper error handling in custom hooks
Highlight any hook design issues, performance problems, or reusability concerns. Highlight any hook design issues, performance problems, or reusability concerns.
- path: "**/lib/**/*.{ts,js}" - path: "**/lib/**/*.{ts,js}"
instructions: | instructions: |
Review utility libraries and helper functions for Termix server management platform. Key considerations: 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 - Resolve improper configuration and environment variable handling
Highlight any utility design issues, performance problems, or security concerns. Highlight any utility design issues, performance problems, or security concerns.
- path: "**/main-axios.ts" - path: "**/main-axios.ts"
instructions: | instructions: |
Review main-axios.ts API client configuration for Termix server management platform. Key considerations: 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 - Identify and fix potential security vulnerabilities in API handling
Highlight any API design issues, error handling problems, or security concerns. Highlight any API design issues, error handling problems, or security concerns.
- path: "**/electron/**/*.{ts,js,cjs}" - path: "**/electron/**/*.{ts,js,cjs}"
instructions: | instructions: |
Review Electron application code for Termix server management platform. Key considerations: 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 - Identify and fix potential security vulnerabilities in Electron setup
Highlight any Electron-specific issues, security vulnerabilities, or performance problems. Highlight any Electron-specific issues, security vulnerabilities, or performance problems.
- path: "**/docker/**/*" - path: "**/docker/**/*"
instructions: | instructions: |
Review Docker configuration files for Termix server management platform. Key considerations: Review Docker configuration files for Termix server management platform. Key considerations:
@@ -505,7 +505,7 @@ reviews:
- Use proper visual aids and diagrams where appropriate - Use proper visual aids and diagrams where appropriate
Highlight any documentation issues, inaccuracies, or missing information. Highlight any documentation issues, inaccuracies, or missing information.
- path: "**/index.css" - path: "**/index.css"
instructions: | instructions: |
Review index.css styling configuration for Termix server management platform. Key considerations: Review index.css styling configuration for Termix server management platform. Key considerations:

3
.env
View File

@@ -1,3 +1,2 @@
VERSION=1.6.0 VERSION=1.6.0
VITE_API_HOST=localhost VITE_API_HOST=localhost
CREDENTIAL_ENCRYPTION_KEY=98fbfabe84b125db7cbbb5168eb584aaecc2f3779a2aaa955c57bdd305071a84

View File

@@ -1,4 +1,4 @@
# Contributing _# Contributing
## Prerequisites ## Prerequisites
@@ -26,6 +26,7 @@ npm run dev
npm run dev:backend 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/`. This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
## Contributing ## Contributing
@@ -59,43 +60,48 @@ This will start the backend and the frontend Vite server. You can access Termix
## Color Scheme ## Color Scheme
### Background Colors ### Background Colors
| CSS Variable | Color Value | Usage | Description |
|--------------|-------------|-------|-------------| | 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` | `#18181b` | Main dark background | Primary dark background color |
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) | | `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background | | `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background | | `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards | | `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover | | `--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 ### Element-Specific Backgrounds
| CSS Variable | Color Value | Usage | Description |
|--------------|-------------|-------|-------------| | 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-input` | `#222225` | Input fields | Background for input fields and form elements |
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements | | `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars | | `--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 ### Border Colors
| CSS Variable | Color Value | Usage | Description |
|--------------|-------------|-------|-------------| | 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` | `#303032` | Default borders | Standard border color |
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states | | `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
| `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements | | `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color | | `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards | | `--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 ### 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 ## 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.

View File

@@ -1,7 +1,7 @@
# Repo Stats # 仓库统计
<p align="center"> <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"> 中文 <img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
</p> </p>
@@ -9,7 +9,9 @@
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) ![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) ![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a> <a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
#### Top Technologies
#### 核心技术
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#) [![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#) [![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#)
[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#) [![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
@@ -28,16 +30,18 @@
如果你愿意,可以在这里支持这个项目!\ 如果你愿意,可以在这里支持这个项目!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Overview # 概览
<p align="center"> <p align="center">
<a href="https://github.com/LukeGus/Termix"> <a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a> <img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
</p> </p>
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案通过一个直观的界面管理你的服务器和基础设施。Termix 提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。 Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案通过一个直观的界面管理你的服务器和基础设施。Termix
提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
# 功能
# Features
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统 - **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控 - **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控
- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等) - **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等)
@@ -47,14 +51,17 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面 - **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面
- **语言支持** - 内置中英文支持 - **语言支持** - 内置中英文支持
# Planned Features # 计划功能
- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能 - **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能
- **主题定制** - 修改所有工具的主题风格 - **主题定制** - 修改所有工具的主题风格
- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我) - **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我)
- **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器 - **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器
# Installation # 安装
访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件: 访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件:
```yaml ```yaml
services: services:
termix: termix:
@@ -70,13 +77,15 @@ services:
volumes: volumes:
termix-data: 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"> <p align="center">
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/> <img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
@@ -95,6 +104,6 @@ volumes:
</video> </video>
</p> </p>
# License # 许可证
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。

View File

@@ -1,4 +1,5 @@
# Repo Stats # Repo Stats
<p align="center"> <p align="center">
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English | <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> <a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
@@ -9,7 +10,9 @@
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) ![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) ![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a> <a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
#### Top Technologies #### Top Technologies
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#) [![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#) [![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#)
[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#) [![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
@@ -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> <img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
</p> </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 # Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system - **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 - **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 - **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 - **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 - **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 - **Modern UI** - Clean mobile friendly (in progress) interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support for English and Chinese - **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 # 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 # 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 ```yaml
services: services:
termix: termix:
@@ -70,10 +83,16 @@ volumes:
termix-data: termix-data:
driver: local 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 # 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 # Show-off
@@ -95,4 +114,5 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
</p> </p>
# License # License
Distributed under the Apache License Version 2.0. See LICENSE for more information. Distributed under the Apache License Version 2.0. See LICENSE for more information.

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import {useState} from 'react';
import { toast } from 'sonner'; import {toast} from 'sonner';
interface ConfirmationOptions { interface ConfirmationOptions {
title: string; title: string;
@@ -35,11 +35,10 @@ export function useConfirmation() {
setOnConfirm(null); setOnConfirm(null);
}; };
// For simple confirmations, we can use a toast with action
const confirmWithToast = (message: string, callback: () => void, variant: 'default' | 'destructive' = 'default') => { const confirmWithToast = (message: string, callback: () => void, variant: 'default' | 'destructive' = 'default') => {
const actionText = variant === 'destructive' ? 'Delete' : 'Confirm'; const actionText = variant === 'destructive' ? 'Delete' : 'Confirm';
const cancelText = 'Cancel'; const cancelText = 'Cancel';
toast(message, { toast(message, {
action: { action: {
label: actionText, label: actionText,
@@ -47,9 +46,10 @@ export function useConfirmation() {
}, },
cancel: { cancel: {
label: cancelText, label: cancelText,
onClick: () => {} onClick: () => {
}
}, },
duration: 10000, // Longer duration for confirmations duration: 10000,
className: variant === 'destructive' ? 'border-red-500' : '' className: variant === 'destructive' ? 'border-red-500' : ''
}); });
}; };

View File

@@ -3,17 +3,17 @@ import * as React from "react"
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
} }
mql.addEventListener("change", onChange) mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener("change", onChange)
}, []) }, [])
return !!isMobile return !!isMobile
} }

View File

@@ -1,47 +1,42 @@
// i18n configuration for multi-language support
import i18n from 'i18next'; import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import {initReactI18next} from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
// Import translation files directly
import enTranslation from '../locales/en/translation.json'; import enTranslation from '../locales/en/translation.json';
import zhTranslation from '../locales/zh/translation.json'; import zhTranslation from '../locales/zh/translation.json';
// Initialize i18n
i18n i18n
.use(LanguageDetector) // Detect user language .use(LanguageDetector)
.use(initReactI18next) // Pass i18n instance to react-i18next .use(initReactI18next)
.init({ .init({
supportedLngs: ['en', 'zh'], // Supported languages supportedLngs: ['en', 'zh'],
fallbackLng: 'en', // Fallback language fallbackLng: 'en',
debug: false, debug: false,
// Detection options - disabled to always use English by default detection: {
detection: { order: ['localStorage', 'cookie'],
order: ['localStorage', 'cookie'], // Only check user's saved preference caches: ['localStorage', 'cookie'],
caches: ['localStorage', 'cookie'], lookupLocalStorage: 'i18nextLng',
lookupLocalStorage: 'i18nextLng', lookupCookie: 'i18nextLng',
lookupCookie: 'i18nextLng', checkWhitelist: true,
checkWhitelist: true, },
},
resources: {
// Resources - load translations directly en: {
resources: { translation: enTranslation
en: { },
translation: enTranslation zh: {
}, translation: zhTranslation
zh: { }
translation: zhTranslation },
}
}, interpolation: {
escapeValue: false,
interpolation: { },
escapeValue: false, // React already escapes values
}, react: {
useSuspense: false,
react: { },
useSuspense: false, // Disable suspense for SSR compatibility });
},
});
export default i18n; export default i18n;

View File

@@ -180,24 +180,24 @@
} }
.thin-scrollbar::-webkit-scrollbar { .thin-scrollbar::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
.thin-scrollbar::-webkit-scrollbar-track { .thin-scrollbar::-webkit-scrollbar-track {
background: #18181b; background: #18181b;
} }
.thin-scrollbar::-webkit-scrollbar-thumb { .thin-scrollbar::-webkit-scrollbar-thumb {
background: #434345; background: #434345;
border-radius: 3px; border-radius: 3px;
} }
.thin-scrollbar::-webkit-scrollbar-thumb:hover { .thin-scrollbar::-webkit-scrollbar-thumb:hover {
background: #5a5a5d; background: #5a5a5d;
} }
.thin-scrollbar { .thin-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #434345 #18181b; scrollbar-color: #434345 #18181b;
} }

View File

@@ -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 type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
export interface LogContext { export interface LogContext {
@@ -21,6 +16,7 @@ export interface LogContext {
retryCount?: number; retryCount?: number;
errorCode?: string; errorCode?: string;
errorMessage?: string; errorMessage?: string;
[key: string]: any; [key: string]: any;
} }
@@ -46,7 +42,7 @@ class FrontendLogger {
const timestamp = this.getTimeStamp(); const timestamp = this.getTimeStamp();
const levelTag = this.getLevelTag(level); const levelTag = this.getLevelTag(level);
const serviceTag = this.getServiceTag(); const serviceTag = this.getServiceTag();
let contextStr = ''; let contextStr = '';
if (context && this.isDevelopment) { if (context && this.isDevelopment) {
const contextParts = []; const contextParts = [];
@@ -58,7 +54,7 @@ class FrontendLogger {
if (context.responseTime) contextParts.push(`${context.responseTime}ms`); if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
if (context.status) contextParts.push(`status:${context.status}`); if (context.status) contextParts.push(`status:${context.status}`);
if (context.errorCode) contextParts.push(`code:${context.errorCode}`); if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
if (contextParts.length > 0) { if (contextParts.length > 0) {
contextStr = ` (${contextParts.join(', ')})`; contextStr = ` (${contextParts.join(', ')})`;
} }
@@ -91,9 +87,9 @@ class FrontendLogger {
private log(level: LogLevel, message: string, context?: LogContext, error?: unknown): void { private log(level: LogLevel, message: string, context?: LogContext, error?: unknown): void {
if (!this.shouldLog(level)) return; if (!this.shouldLog(level)) return;
const formattedMessage = this.formatMessage(level, message, context); const formattedMessage = this.formatMessage(level, message, context);
switch (level) { switch (level) {
case 'debug': case 'debug':
console.debug(formattedMessage); console.debug(formattedMessage);
@@ -136,60 +132,58 @@ class FrontendLogger {
this.log('success', message, context); this.log('success', message, context);
} }
// Convenience methods for common operations
api(message: string, context?: LogContext): void { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { requestStart(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`); console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
this.request(`→ Starting request to ${cleanUrl}`, { this.request(`→ Starting request to ${cleanUrl}`, {
...context, ...context,
@@ -203,7 +197,7 @@ class FrontendLogger {
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status); const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime); const performanceIcon = this.getPerformanceIcon(responseTime);
this.response(`${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, { this.response(`${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
@@ -218,7 +212,7 @@ class FrontendLogger {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status); const statusIcon = this.getStatusIcon(status);
this.error(`${statusIcon} ${status} ${errorMessage}`, undefined, { this.error(`${statusIcon} ${status} ${errorMessage}`, undefined, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
@@ -233,7 +227,7 @@ class FrontendLogger {
networkError(method: string, url: string, errorMessage: string, context?: LogContext): void { networkError(method: string, url: string, errorMessage: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
this.error(`🌐 Network Error: ${errorMessage}`, undefined, { this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
@@ -247,7 +241,7 @@ class FrontendLogger {
authError(method: string, url: string, context?: LogContext): void { authError(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
this.security(`🔐 Authentication Required`, { this.security(`🔐 Authentication Required`, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
@@ -260,7 +254,7 @@ class FrontendLogger {
retryAttempt(method: string, url: string, attempt: number, maxAttempts: number, context?: LogContext): void { retryAttempt(method: string, url: string, attempt: number, maxAttempts: number, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, { this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
@@ -269,25 +263,22 @@ class FrontendLogger {
}); });
} }
// Enhanced logging for API operations
apiOperation(operation: string, details: string, context?: LogContext): void { 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 { requestSummary(method: string, url: string, status: number, responseTime: number, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status); const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime); const performanceIcon = this.getPerformanceIcon(responseTime);
console.log(`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, console.log(`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
'color: #666; font-style: italic; font-size: 0.9em;', 'color: #666; font-style: italic; font-size: 0.9em;',
context context
); );
} }
// New helper methods for better formatting
private getShortUrl(url: string): string { private getShortUrl(url: string): string {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
@@ -316,10 +307,8 @@ class FrontendLogger {
} }
private sanitizeUrl(url: string): string { private sanitizeUrl(url: string): string {
// Remove sensitive information from URLs for logging
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
// Remove query parameters that might contain sensitive data
if (urlObj.searchParams.has('password') || urlObj.searchParams.has('token')) { if (urlObj.searchParams.has('password') || urlObj.searchParams.has('token')) {
urlObj.search = ''; urlObj.search = '';
} }
@@ -330,7 +319,6 @@ class FrontendLogger {
} }
} }
// Service-specific loggers
export const apiLogger = new FrontendLogger('API', '🌐', '#3b82f6'); export const apiLogger = new FrontendLogger('API', '🌐', '#3b82f6');
export const authLogger = new FrontendLogger('AUTH', '🔐', '#dc2626'); export const authLogger = new FrontendLogger('AUTH', '🔐', '#dc2626');
export const sshLogger = new FrontendLogger('SSH', '🖥️', '#1e3a8a'); 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 statsLogger = new FrontendLogger('STATS', '📊', '#22c55e');
export const systemLogger = new FrontendLogger('SYSTEM', '🚀', '#1e3a8a'); export const systemLogger = new FrontendLogger('SYSTEM', '🚀', '#1e3a8a');
// Default logger for general use
export const logger = systemLogger; export const logger = systemLogger;

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import {clsx, type ClassValue} from "clsx"
import { twMerge } from "tailwind-merge" import {twMerge} from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }

View File

@@ -10,7 +10,7 @@
"deleteCredential": "Delete Credential", "deleteCredential": "Delete Credential",
"updateCredential": "Update Credential", "updateCredential": "Update Credential",
"credentialName": "Credential Name", "credentialName": "Credential Name",
"credentialDescription": "Description", "credentialDescription": "Description",
"username": "Username", "username": "Username",
"searchCredentials": "Search credentials...", "searchCredentials": "Search credentials...",
"selectFolder": "Select Folder", "selectFolder": "Select Folder",
@@ -42,7 +42,7 @@
"credentialsCount": "{{count}} credentials", "credentialsCount": "{{count}} credentials",
"refresh": "Refresh", "refresh": "Refresh",
"passwordRequired": "Password is required", "passwordRequired": "Password is required",
"sshKeyRequired": "SSH key is required", "sshKeyRequired": "SSH key is required",
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully", "credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
"general": "General", "general": "General",
"description": "Description", "description": "Description",
@@ -57,7 +57,7 @@
"keyPassword": "Key Password (optional)", "keyPassword": "Key Password (optional)",
"keyType": "Key Type", "keyType": "Key Type",
"keyTypeRSA": "RSA", "keyTypeRSA": "RSA",
"keyTypeECDSA": "ECDSA", "keyTypeECDSA": "ECDSA",
"keyTypeEd25519": "Ed25519", "keyTypeEd25519": "Ed25519",
"updateCredential": "Update Credential", "updateCredential": "Update Credential",
"basicInfo": "Basic Info", "basicInfo": "Basic Info",
@@ -224,7 +224,7 @@
"register": "Register", "register": "Register",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"version" : "Version", "version": "Version",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
"back": "Back", "back": "Back",
"email": "Email", "email": "Email",

View File

@@ -2,7 +2,7 @@ import {StrictMode, useEffect, useState, useRef} from 'react'
import {createRoot} from 'react-dom/client' import {createRoot} from 'react-dom/client'
import './index.css' import './index.css'
import DesktopApp from './ui/Desktop/DesktopApp.tsx' 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 {ThemeProvider} from "@/components/theme-provider"
import './i18n/i18n' import './i18n/i18n'
import {isElectron} from './ui/main-axios.ts' import {isElectron} from './ui/main-axios.ts'
@@ -54,10 +54,10 @@ function RootApp() {
const width = useWindowWidth(); const width = useWindowWidth();
const isMobile = width < 768; const isMobile = width < 768;
if (isElectron()) { 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( createRoot(document.getElementById('root')!).render(

View File

@@ -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;
}

View File

@@ -4,7 +4,7 @@
// This file contains all shared interfaces and types used across the application // This file contains all shared interfaces and types used across the application
// to avoid duplication and ensure consistency. // to avoid duplication and ensure consistency.
import type { Client } from 'ssh2'; import type {Client} from 'ssh2';
// ============================================================================ // ============================================================================
// SSH HOST TYPES // SSH HOST TYPES

View File

@@ -74,24 +74,19 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
React.useEffect(() => { React.useEffect(() => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (!jwt) return; if (!jwt) return;
// Check if we're in Electron and have a server configured
if (isElectron()) { if (isElectron()) {
// In Electron, check if we have a configured server
const serverUrl = (window as any).configuredServerUrl; const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) { if (!serverUrl) {
console.log('No server configured in Electron, skipping API calls');
return; return;
} }
} }
getOIDCConfig() getOIDCConfig()
.then(res => { .then(res => {
if (res) setOidcConfig(res); if (res) setOidcConfig(res);
}) })
.catch((err) => { .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')) { if (!err.message?.includes('No server configured')) {
toast.error(t('admin.failedToFetchOidcConfig')); toast.error(t('admin.failedToFetchOidcConfig'));
} }
@@ -100,15 +95,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
// Check if we're in Electron and have a server configured
if (isElectron()) { if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl; const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) { if (!serverUrl) {
console.log('No server configured in Electron, skipping registration status check');
return; return;
} }
} }
getRegistrationAllowed() getRegistrationAllowed()
.then(res => { .then(res => {
if (typeof res?.allowed === 'boolean') { if (typeof res?.allowed === 'boolean') {
@@ -116,8 +109,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
} }
}) })
.catch((err) => { .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')) { if (!err.message?.includes('No server configured')) {
toast.error(t('admin.failedToFetchRegistrationStatus')); toast.error(t('admin.failedToFetchRegistrationStatus'));
} }
@@ -127,23 +118,19 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const fetchUsers = async () => { const fetchUsers = async () => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (!jwt) return; if (!jwt) return;
// Check if we're in Electron and have a server configured
if (isElectron()) { if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl; const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) { if (!serverUrl) {
console.log('No server configured in Electron, skipping user fetch');
return; return;
} }
} }
setUsersLoading(true); setUsersLoading(true);
try { try {
const response = await getUserList(); const response = await getUserList();
setUsers(response.users); setUsers(response.users);
} catch (err) { } 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')) { if (!err.message?.includes('No server configured')) {
toast.error(t('admin.failedToFetchUsers')); 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 required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]); const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
if (missing.length > 0) { if (missing.length > 0) {
setOidcError(t('admin.missingRequiredFields', { fields: missing.join(', ') })); setOidcError(t('admin.missingRequiredFields', {fields: missing.join(', ')}));
setOidcLoading(false); setOidcLoading(false);
return; return;
} }
@@ -199,7 +186,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await makeUserAdmin(newAdminUsername.trim()); await makeUserAdmin(newAdminUsername.trim());
toast.success(t('admin.userIsNowAdmin', { username: newAdminUsername })); toast.success(t('admin.userIsNowAdmin', {username: newAdminUsername}));
setNewAdminUsername(""); setNewAdminUsername("");
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
@@ -211,15 +198,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const handleRemoveAdminStatus = async (username: string) => { const handleRemoveAdminStatus = async (username: string) => {
confirmWithToast( confirmWithToast(
t('admin.removeAdminStatus', { username }), t('admin.removeAdminStatus', {username}),
async () => { async () => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await removeAdminStatus(username); await removeAdminStatus(username);
toast.success(t('admin.adminStatusRemoved', { username })); toast.success(t('admin.adminStatusRemoved', {username}));
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
console.error('Failed to remove admin status:', err);
toast.error(t('admin.failedToRemoveAdminStatus')); toast.error(t('admin.failedToRemoveAdminStatus'));
} }
} }
@@ -228,15 +214,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const handleDeleteUser = async (username: string) => { const handleDeleteUser = async (username: string) => {
confirmWithToast( confirmWithToast(
t('admin.deleteUser', { username }), t('admin.deleteUser', {username}),
async () => { async () => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await deleteUser(username); await deleteUser(username);
toast.success(t('admin.userDeletedSuccessfully', { username })); toast.success(t('admin.userDeletedSuccessfully', {username}));
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
console.error('Failed to delete user:', err);
toast.error(t('admin.failedToDeleteUser')); 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> <h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p> <p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 px-3 text-xs" className="h-8 px-3 text-xs"
onClick={() => window.open('https://docs.termix.site/oidc', '_blank')} 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"> <div className="space-y-2">
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label> <Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
<PasswordInput id="client_secret" value={oidcConfig.client_secret} <PasswordInput id="client_secret" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder={t('placeholders.clientSecret')} required/> placeholder={t('placeholders.clientSecret')} required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label> <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> size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
</div> </div>
{usersLoading ? ( {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"> <div className="border rounded-md overflow-hidden">
<Table> <Table>

View File

@@ -1,31 +1,28 @@
import { zodResolver } from "@hookform/resolvers/zod" import {zodResolver} from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form" import {Controller, useForm} from "react-hook-form"
import { z } from "zod" import {z} from "zod"
import { Button } from "@/components/ui/button" import {Button} from "@/components/ui/button"
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage,
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import {Input} from "@/components/ui/input"
import { PasswordInput } from "@/components/ui/password-input" import {PasswordInput} from "@/components/ui/password-input"
import { ScrollArea } from "@/components/ui/scroll-area" import {ScrollArea} from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator" import {Separator} from "@/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import React, { useEffect, useRef, useState } from "react" import React, {useEffect, useRef, useState} from "react"
import { Alert, AlertDescription } from "@/components/ui/alert" import {toast} from "sonner"
import { toast } from "sonner" import {createCredential, updateCredential, getCredentials, getCredentialDetails} from '@/ui/main-axios'
import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios' import {useTranslation} from "react-i18next"
import { useTranslation } from "react-i18next" import type {Credential, CredentialEditorProps, CredentialData} from '../../../../types/index.js'
import type { Credential, CredentialEditorProps, CredentialData } from '../../../../types/index.js'
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) { export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEditorProps) {
const { t } = useTranslation(); const {t} = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]); const [credentials, setCredentials] = useState<Credential[]>([]);
const [folders, setFolders] = useState<string[]>([]); const [folders, setFolders] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -64,7 +61,6 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
const fullDetails = await getCredentialDetails(editingCredential.id); const fullDetails = await getCredentialDetails(editingCredential.id);
setFullCredentialDetails(fullDetails); setFullCredentialDetails(fullDetails);
} catch (error) { } catch (error) {
console.error('Failed to fetch credential details:', error);
toast.error(t('credentials.failedToFetchCredentialDetails')); toast.error(t('credentials.failedToFetchCredentialDetails'));
} }
} else { } else {
@@ -139,7 +135,6 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
const defaultAuthType = fullCredentialDetails.authType; const defaultAuthType = fullCredentialDetails.authType;
setAuthTab(defaultAuthType); setAuthTab(defaultAuthType);
// Force form reset with a small delay to ensure proper rendering
setTimeout(() => { setTimeout(() => {
const formData = { const formData = {
name: fullCredentialDetails.name || "", name: fullCredentialDetails.name || "",
@@ -153,16 +148,15 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
keyPassword: "", keyPassword: "",
keyType: "auto" as const, keyType: "auto" as const,
}; };
// Only set the relevant authentication fields based on authType
if (defaultAuthType === 'password') { if (defaultAuthType === 'password') {
formData.password = fullCredentialDetails.password || ""; formData.password = fullCredentialDetails.password || "";
} else if (defaultAuthType === 'key') { } else if (defaultAuthType === 'key') {
formData.key = "existing_key"; // Placeholder to indicate existing key formData.key = "existing_key";
formData.keyPassword = fullCredentialDetails.keyPassword || ""; formData.keyPassword = fullCredentialDetails.keyPassword || "";
formData.keyType = (fullCredentialDetails.keyType as any) || "auto" as const; formData.keyType = (fullCredentialDetails.keyType as any) || "auto" as const;
} }
form.reset(formData); form.reset(formData);
setTagInput(""); setTagInput("");
}, 100); }, 100);
@@ -222,10 +216,10 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
if (editingCredential) { if (editingCredential) {
await updateCredential(editingCredential.id, submitData); await updateCredential(editingCredential.id, submitData);
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: data.name })); toast.success(t('credentials.credentialUpdatedSuccessfully', {name: data.name}));
} else { } else {
await createCredential(submitData); await createCredential(submitData);
toast.success(t('credentials.credentialAddedSuccessfully', { name: data.name })); toast.success(t('credentials.credentialAddedSuccessfully', {name: data.name}));
} }
if (onFormSubmit) { if (onFormSubmit) {
@@ -233,8 +227,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
} }
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent('credentials:changed'));
// Reset form after successful submission
form.reset(); form.reset();
} catch (error) { } catch (error) {
toast.error(t('credentials.failedToSaveCredential')); toast.error(t('credentials.failedToSaveCredential'));
@@ -282,15 +275,15 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
}, [folderDropdownOpen]); }, [folderDropdownOpen]);
const keyTypeOptions = [ const keyTypeOptions = [
{ value: 'auto', label: t('hosts.autoDetect') }, {value: 'auto', label: t('hosts.autoDetect')},
{ value: 'ssh-rsa', label: t('hosts.rsa') }, {value: 'ssh-rsa', label: t('hosts.rsa')},
{ value: 'ssh-ed25519', label: t('hosts.ed25519') }, {value: 'ssh-ed25519', label: t('hosts.ed25519')},
{ value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256') }, {value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256')},
{ value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384') }, {value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384')},
{ value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521') }, {value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521')},
{ value: 'ssh-dss', label: t('hosts.dsa') }, {value: 'ssh-dss', label: t('hosts.dsa')},
{ value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256') }, {value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256')},
{ value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512') }, {value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512')},
]; ];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
@@ -330,7 +323,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({field}) => (
<FormItem className="col-span-6"> <FormItem className="col-span-6">
<FormLabel>{t('credentials.credentialName')}</FormLabel> <FormLabel>{t('credentials.credentialName')}</FormLabel>
<FormControl> <FormControl>
@@ -343,7 +336,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="username" name="username"
render={({ field }) => ( render={({field}) => (
<FormItem className="col-span-6"> <FormItem className="col-span-6">
<FormLabel>{t('credentials.username')}</FormLabel> <FormLabel>{t('credentials.username')}</FormLabel>
<FormControl> <FormControl>
@@ -358,7 +351,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({field}) => (
<FormItem className="col-span-10"> <FormItem className="col-span-10">
<FormLabel>{t('credentials.description')}</FormLabel> <FormLabel>{t('credentials.description')}</FormLabel>
<FormControl> <FormControl>
@@ -371,7 +364,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="folder" name="folder"
render={({ field }) => ( render={({field}) => (
<FormItem className="col-span-10 relative"> <FormItem className="col-span-10 relative">
<FormLabel>{t('credentials.folder')}</FormLabel> <FormLabel>{t('credentials.folder')}</FormLabel>
<FormControl> <FormControl>
@@ -416,7 +409,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="tags" name="tags"
render={({ field }) => ( render={({field}) => (
<FormItem className="col-span-10 overflow-visible"> <FormItem className="col-span-10 overflow-visible">
<FormLabel>{t('credentials.tags')}</FormLabel> <FormLabel>{t('credentials.tags')}</FormLabel>
<FormControl> <FormControl>
@@ -482,18 +475,14 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
const newAuthType = value as 'password' | 'key'; const newAuthType = value as 'password' | 'key';
setAuthTab(newAuthType); setAuthTab(newAuthType);
form.setValue('authType', newAuthType); form.setValue('authType', newAuthType);
// Clear ALL authentication fields first
form.setValue('password', ''); form.setValue('password', '');
form.setValue('key', null); form.setValue('key', null);
form.setValue('keyPassword', ''); form.setValue('keyPassword', '');
form.setValue('keyType', 'auto'); form.setValue('keyType', 'auto');
// Then set only the relevant fields based on auth type
if (newAuthType === 'password') { if (newAuthType === 'password') {
// Password fields will be filled by user
} else if (newAuthType === 'key') { } else if (newAuthType === 'key') {
// Key fields will be filled by user
} }
}} }}
className="flex-1 flex flex-col h-full min-h-0" className="flex-1 flex flex-col h-full min-h-0"
@@ -506,11 +495,12 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="password" name="password"
render={({ field }) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>{t('credentials.password')}</FormLabel> <FormLabel>{t('credentials.password')}</FormLabel>
<FormControl> <FormControl>
<PasswordInput placeholder={t('placeholders.password')} {...field} /> <PasswordInput
placeholder={t('placeholders.password')} {...field} />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -521,7 +511,6 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
value={keyInputMethod} value={keyInputMethod}
onValueChange={(value) => { onValueChange={(value) => {
setKeyInputMethod(value as 'upload' | 'paste'); setKeyInputMethod(value as 'upload' | 'paste');
// Clear the other field when switching
if (value === 'upload') { if (value === 'upload') {
form.setValue('key', null); form.setValue('key', null);
} else { } else {
@@ -530,7 +519,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
}} }}
className="w-full" 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="upload">{t('hosts.uploadFile')}</TabsTrigger>
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger> <TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
</TabsList> </TabsList>
@@ -538,7 +528,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<Controller <Controller
control={form.control} control={form.control}
name="key" name="key"
render={({ field }) => ( render={({field}) => (
<FormItem className="mb-4"> <FormItem className="mb-4">
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel> <FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
<FormControl> <FormControl>
@@ -560,8 +550,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
> >
<span className="truncate" <span className="truncate"
title={field.value?.name || t('credentials.upload')}> title={field.value?.name || t('credentials.upload')}>
{field.value === "existing_key" ? t('hosts.existingKey') : {field.value === "existing_key" ? t('hosts.existingKey') :
field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')} field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
</span> </span>
</Button> </Button>
</div> </div>
@@ -573,7 +563,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="keyPassword" name="keyPassword"
render={({ field }) => ( render={({field}) => (
<FormItem className="col-span-8"> <FormItem className="col-span-8">
<FormLabel>{t('credentials.keyPassword')}</FormLabel> <FormLabel>{t('credentials.keyPassword')}</FormLabel>
<FormControl> <FormControl>
@@ -588,7 +578,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="keyType" name="keyType"
render={({ field }) => ( render={({field}) => (
<FormItem className="relative col-span-3"> <FormItem className="relative col-span-3">
<FormLabel>{t('credentials.keyType')}</FormLabel> <FormLabel>{t('credentials.keyType')}</FormLabel>
<FormControl> <FormControl>
@@ -607,7 +597,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
ref={keyTypeDropdownRef} 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" 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) => ( {keyTypeOptions.map((opt) => (
<Button <Button
key={opt.value} key={opt.value}
@@ -637,7 +628,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<Controller <Controller
control={form.control} control={form.control}
name="key" name="key"
render={({ field }) => ( render={({field}) => (
<FormItem className="mb-4"> <FormItem className="mb-4">
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel> <FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
<FormControl> <FormControl>
@@ -655,7 +646,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="keyPassword" name="keyPassword"
render={({ field }) => ( render={({field}) => (
<FormItem className="col-span-8"> <FormItem className="col-span-8">
<FormLabel>{t('credentials.keyPassword')}</FormLabel> <FormLabel>{t('credentials.keyPassword')}</FormLabel>
<FormControl> <FormControl>
@@ -670,7 +661,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<FormField <FormField
control={form.control} control={form.control}
name="keyType" name="keyType"
render={({ field }) => ( render={({field}) => (
<FormItem className="relative col-span-3"> <FormItem className="relative col-span-3">
<FormLabel>{t('credentials.keyType')}</FormLabel> <FormLabel>{t('credentials.keyType')}</FormLabel>
<FormControl> <FormControl>
@@ -689,7 +680,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
ref={keyTypeDropdownRef} 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" 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) => ( {keyTypeOptions.map((opt) => (
<Button <Button
key={opt.value} key={opt.value}

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'; import React, {useState, useEffect, useRef} from 'react';
import { Button } from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx"; import {Input} from "@/components/ui/input.tsx";
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx"; import {FormControl, FormItem, FormLabel} from "@/components/ui/form.tsx";
import { getCredentials } from '@/ui/main-axios.ts'; import {getCredentials} from '@/ui/main-axios.ts';
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import type { Credential } from '../../../../types'; import type {Credential} from '../../../../types';
interface CredentialSelectorProps { interface CredentialSelectorProps {
value?: number | null; value?: number | null;
@@ -12,8 +12,8 @@ interface CredentialSelectorProps {
onCredentialSelect?: (credential: Credential | null) => void; onCredentialSelect?: (credential: Credential | null) => void;
} }
export function CredentialSelector({ value, onValueChange, onCredentialSelect }: CredentialSelectorProps) { export function CredentialSelector({value, onValueChange, onCredentialSelect}: CredentialSelectorProps) {
const { t } = useTranslation(); const {t} = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]); const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false); 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 || []); const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []);
setCredentials(credentialsArray); setCredentials(credentialsArray);
} catch (error) { } catch (error) {
console.error('Failed to fetch credentials:', error);
const {toast} = await import('sonner'); const {toast} = await import('sonner');
toast.error(t('credentials.failedToFetchCredentials')); toast.error(t('credentials.failedToFetchCredentials'));
setCredentials([]); setCredentials([]);
@@ -128,7 +127,7 @@ export function CredentialSelector({ value, onValueChange, onCredentialSelect }:
t('hosts.selectCredentialPlaceholder') t('hosts.selectCredentialPlaceholder')
)} )}
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
</Button> </Button>

View File

@@ -1,36 +1,34 @@
import React, { useState, useEffect } from 'react'; import React, {useState, useEffect} from 'react';
import { Button } from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area"; import {ScrollArea} from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import {Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet";
import { import {
Key, Key,
User, User,
Calendar, Calendar,
Hash, Hash,
Folder, Folder,
Edit3, Edit3,
Copy, Copy,
Settings,
Shield, Shield,
Clock, Clock,
Server, Server,
Eye, Eye,
EyeOff, EyeOff,
ExternalLink,
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
FileText FileText
} from 'lucide-react'; } from 'lucide-react';
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios'; import {getCredentialDetails, getCredentialHosts} from '@/ui/main-axios';
import { toast } from 'sonner'; import {toast} from 'sonner';
import { useTranslation } from 'react-i18next'; import {useTranslation} from 'react-i18next';
import type { Credential, HostInfo, CredentialViewerProps } from '../../../types/index.js'; import type {Credential, HostInfo, CredentialViewerProps} from '../../../types/index.js';
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => { const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose, onEdit}) => {
const { t } = useTranslation(); const {t} = useTranslation();
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null); const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null);
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]); const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -47,7 +45,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
const response = await getCredentialDetails(credential.id); const response = await getCredentialDetails(credential.id);
setCredentialDetails(response); setCredentialDetails(response);
} catch (error) { } catch (error) {
console.error('Failed to fetch credential details:', error);
toast.error(t('credentials.failedToFetchCredentialDetails')); toast.error(t('credentials.failedToFetchCredentialDetails'));
} }
}; };
@@ -57,7 +54,6 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
const response = await getCredentialHosts(credential.id); const response = await getCredentialHosts(credential.id);
setHostsUsing(response); setHostsUsing(response);
} catch (error) { } catch (error) {
console.error('Failed to fetch hosts using credential:', error);
toast.error(t('credentials.failedToFetchHostsUsing')); toast.error(t('credentials.failedToFetchHostsUsing'));
} finally { } finally {
setLoading(false); setLoading(false);
@@ -74,7 +70,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
const copyToClipboard = async (text: string, fieldName: string) => { const copyToClipboard = async (text: string, fieldName: string) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
toast.success(t('copiedToClipboard', { field: fieldName })); toast.success(t('copiedToClipboard', {field: fieldName}));
} catch (error) { } catch (error) {
toast.error(t('credentials.failedToCopy')); toast.error(t('credentials.failedToCopy'));
} }
@@ -86,9 +82,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
const getAuthIcon = (authType: string) => { const getAuthIcon = (authType: string) => {
return authType === 'password' ? ( 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" size="sm"
onClick={() => toggleSensitiveVisibility(fieldName)} 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>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => copyToClipboard(value, label)} onClick={() => copyToClipboard(value, label)}
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4"/>
</Button> </Button>
</div> </div>
</div> </div>
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}> <div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
{isVisible ? ( {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} {value}
</pre> </pre>
) : ( ) : (
@@ -167,11 +164,13 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <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} {credentialDetails.authType}
</Badge> </Badge>
{credentialDetails.keyType && ( {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} {credentialDetails.keyType}
</Badge> </Badge>
)} )}
@@ -181,14 +180,15 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
<div className="space-y-10"> <div className="space-y-10">
{/* Tab Navigation */} {/* 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 <Button
variant={activeTab === 'overview' ? 'default' : 'ghost'} variant={activeTab === 'overview' ? 'default' : 'ghost'}
size="sm" size="sm"
onClick={() => setActiveTab('overview')} onClick={() => setActiveTab('overview')}
className="flex-1 h-10" className="flex-1 h-10"
> >
<FileText className="h-4 w-4 mr-2" /> <FileText className="h-4 w-4 mr-2"/>
{t('credentials.overview')} {t('credentials.overview')}
</Button> </Button>
<Button <Button
@@ -197,7 +197,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
onClick={() => setActiveTab('security')} onClick={() => setActiveTab('security')}
className="flex-1 h-10" className="flex-1 h-10"
> >
<Shield className="h-4 w-4 mr-2" /> <Shield className="h-4 w-4 mr-2"/>
{t('credentials.security')} {t('credentials.security')}
</Button> </Button>
<Button <Button
@@ -206,7 +206,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
onClick={() => setActiveTab('usage')} onClick={() => setActiveTab('usage')}
className="flex-1 h-10" className="flex-1 h-10"
> >
<Server className="h-4 w-4 mr-2" /> <Server className="h-4 w-4 mr-2"/>
{t('credentials.usage')} {t('credentials.usage')}
</Button> </Button>
</div> </div>
@@ -216,24 +216,28 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
<div className="grid gap-10 lg:grid-cols-2"> <div className="grid gap-10 lg:grid-cols-2">
<Card className="border-zinc-200 dark:border-zinc-700"> <Card className="border-zinc-200 dark:border-zinc-700">
<CardHeader className="pb-8"> <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> </CardHeader>
<CardContent className="space-y-8"> <CardContent className="space-y-8">
<div className="flex items-center space-x-5"> <div className="flex items-center space-x-5">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800"> <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> <div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div> <div
<div className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</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>
</div> </div>
{credentialDetails.folder && ( {credentialDetails.folder && (
<div className="flex items-center space-x-4"> <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>
<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 className="font-medium">{credentialDetails.folder}</div>
</div> </div>
</div> </div>
@@ -241,9 +245,10 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
{credentialDetails.tags.length > 0 && ( {credentialDetails.tags.length > 0 && (
<div className="flex items-start space-x-4"> <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="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"> <div className="flex flex-wrap gap-2">
{credentialDetails.tags.map((tag, index) => ( {credentialDetails.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs"> <Badge key={index} variant="outline" className="text-xs">
@@ -255,20 +260,22 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
</div> </div>
)} )}
<Separator /> <Separator/>
<div className="flex items-center space-x-4"> <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>
<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 className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4"> <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>
<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 className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
</div> </div>
</div> </div>
@@ -290,19 +297,24 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
</div> </div>
{credentialDetails.lastUsed && ( {credentialDetails.lastUsed && (
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg"> <div
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" /> 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>
<div className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div> <div
<div className="font-medium">{formatDate(credentialDetails.lastUsed)}</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> </div>
)} )}
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg"> <div
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" /> 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>
<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 className="font-medium">{hostsUsing.length}</div>
</div> </div>
</div> </div>
@@ -315,7 +327,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center space-x-2"> <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> <span>{t('credentials.securityDetails')}</span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -323,8 +335,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <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"> <div
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" /> 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>
<div className="font-medium text-zinc-800 dark:text-zinc-200"> <div className="font-medium text-zinc-800 dark:text-zinc-200">
{t('credentials.credentialSecured')} {t('credentials.credentialSecured')}
@@ -345,10 +358,11 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
{credentialDetails.authType === 'key' && ( {credentialDetails.authType === 'key' && (
<div className="space-y-6"> <div className="space-y-6">
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3> <h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div> <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')} {t('credentials.keyType')}
</div> </div>
<Badge variant="outline" className="text-sm"> <Badge variant="outline" className="text-sm">
@@ -358,17 +372,18 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
</div> </div>
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)} {renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)}
{credentialDetails.keyPassword && renderSensitiveField( {credentialDetails.keyPassword && renderSensitiveField(
credentialDetails.keyPassword, credentialDetails.keyPassword,
'keyPassword', 'keyPassword',
t('credentials.keyPassphrase') t('credentials.keyPassphrase')
)} )}
</div> </div>
)} )}
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg"> <div
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" /> 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="text-sm">
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2"> <div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
{t('credentials.securityReminder')} {t('credentials.securityReminder')}
@@ -386,7 +401,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center space-x-2"> <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> <span>{t('credentials.hostsUsingCredential')}</span>
<Badge variant="secondary">{hostsUsing.length}</Badge> <Badge variant="secondary">{hostsUsing.length}</Badge>
</CardTitle> </CardTitle>
@@ -394,20 +409,21 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
<CardContent> <CardContent>
{hostsUsing.length === 0 ? ( {hostsUsing.length === 0 ? (
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400"> <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> <p>{t('credentials.noHostsUsingCredential')}</p>
</div> </div>
) : ( ) : (
<ScrollArea className="h-64"> <ScrollArea className="h-64">
<div className="space-y-3"> <div className="space-y-3">
{hostsUsing.map((host) => ( {hostsUsing.map((host) => (
<div <div
key={host.id} key={host.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800" 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="flex items-center space-x-3">
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded"> <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> <div>
<div className="font-medium"> <div className="font-medium">
@@ -418,7 +434,8 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
</div> </div>
</div> </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)} {formatDate(host.createdAt)}
</div> </div>
</div> </div>
@@ -436,7 +453,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose
{t('common.close')} {t('common.close')}
</Button> </Button>
<Button onClick={onEdit}> <Button onClick={onEdit}>
<Edit3 className="h-4 w-4 mr-2" /> <Edit3 className="h-4 w-4 mr-2"/>
{t('credentials.editCredential')} {t('credentials.editCredential')}
</Button> </Button>
</SheetFooter> </SheetFooter>

View File

@@ -1,13 +1,13 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, {useState, useEffect, useMemo, useRef} from 'react';
import { Button } from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import {ScrollArea} from "@/components/ui/scroll-area";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import { import {
Search, Search,
Key, Key,
Folder, Folder,
Edit, Edit,
Trash2, Trash2,
@@ -20,16 +20,16 @@ import {
X, X,
Check Check
} from 'lucide-react'; } from 'lucide-react';
import { getCredentials, deleteCredential, updateCredential, renameCredentialFolder } from '@/ui/main-axios'; import {getCredentials, deleteCredential, updateCredential, renameCredentialFolder} from '@/ui/main-axios';
import { toast } from 'sonner'; import {toast} from 'sonner';
import { useTranslation } from 'react-i18next'; import {useTranslation} from 'react-i18next';
import { useConfirmation } from '@/hooks/use-confirmation.ts'; import {useConfirmation} from '@/hooks/use-confirmation.ts';
import CredentialViewer from './CredentialViewer'; import CredentialViewer from './CredentialViewer';
import type { Credential, CredentialsManagerProps } from '../../../../types/index.js'; import type {Credential, CredentialsManagerProps} from '../../../../types/index.js';
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) { export function CredentialsManager({onEditCredential}: CredentialsManagerProps) {
const { t } = useTranslation(); const {t} = useTranslation();
const { confirmWithToast } = useConfirmation(); const {confirmWithToast} = useConfirmation();
const [credentials, setCredentials] = useState<Credential[]>([]); const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -61,7 +61,6 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
}; };
const handleEdit = (credential: Credential) => { const handleEdit = (credential: Credential) => {
if (onEditCredential) { if (onEditCredential) {
onEditCredential(credential); onEditCredential(credential);
@@ -71,11 +70,11 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
const handleDelete = async (credentialId: number, credentialName: string) => { const handleDelete = async (credentialId: number, credentialName: string) => {
confirmWithToast( confirmWithToast(
t('credentials.confirmDeleteCredential', { name: credentialName }), t('credentials.confirmDeleteCredential', {name: credentialName}),
async () => { async () => {
try { try {
await deleteCredential(credentialId); await deleteCredential(credentialId);
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName })); toast.success(t('credentials.credentialDeletedSuccessfully', {name: credentialName}));
await fetchCredentials(); await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent('credentials:changed'));
} catch (err: any) { } catch (err: any) {
@@ -93,13 +92,16 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
const handleRemoveFromFolder = async (credential: Credential) => { const handleRemoveFromFolder = async (credential: Credential) => {
confirmWithToast( confirmWithToast(
t('credentials.confirmRemoveFromFolder', { name: credential.name || credential.username, folder: credential.folder }), t('credentials.confirmRemoveFromFolder', {
name: credential.name || credential.username,
folder: credential.folder
}),
async () => { async () => {
try { try {
setOperationLoading(true); setOperationLoading(true);
const updatedCredential = { ...credential, folder: '' }; const updatedCredential = {...credential, folder: ''};
await updateCredential(credential.id, updatedCredential); 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(); await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent('credentials:changed'));
} catch (err) { } catch (err) {
@@ -121,7 +123,7 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
try { try {
setOperationLoading(true); setOperationLoading(true);
await renameCredentialFolder(oldName, editingFolderName.trim()); 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(); await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent('credentials:changed'));
setEditingFolder(null); setEditingFolder(null);
@@ -143,11 +145,10 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
setEditingFolderName(''); setEditingFolderName('');
}; };
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, credential: Credential) => { const handleDragStart = (e: React.DragEvent, credential: Credential) => {
setDraggedCredential(credential); setDraggedCredential(credential);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // Required for Firefox e.dataTransfer.setData('text/plain', '');
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
@@ -182,7 +183,7 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
if (!draggedCredential) return; if (!draggedCredential) return;
const newFolder = targetFolder === t('credentials.uncategorized') ? '' : targetFolder; const newFolder = targetFolder === t('credentials.uncategorized') ? '' : targetFolder;
if (draggedCredential.folder === newFolder) { if (draggedCredential.folder === newFolder) {
setDraggedCredential(null); setDraggedCredential(null);
return; return;
@@ -190,11 +191,11 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
try { try {
setOperationLoading(true); setOperationLoading(true);
const updatedCredential = { ...draggedCredential, folder: newFolder }; const updatedCredential = {...draggedCredential, folder: newFolder};
await updateCredential(draggedCredential.id, updatedCredential); await updateCredential(draggedCredential.id, updatedCredential);
toast.success(t('credentials.movedToFolder', { toast.success(t('credentials.movedToFolder', {
name: draggedCredential.name || draggedCredential.username, name: draggedCredential.name || draggedCredential.username,
folder: targetFolder folder: targetFolder
})); }));
await fetchCredentials(); await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent('credentials:changed'));
@@ -287,7 +288,7 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
<div> <div>
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2> <h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('credentials.credentialsCount', { count: 0 })} {t('credentials.credentialsCount', {count: 0})}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -316,7 +317,7 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
<div> <div>
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2> <h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('credentials.credentialsCount', { count: filteredAndSortedCredentials.length })} {t('credentials.credentialsCount', {count: filteredAndSortedCredentials.length})}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -339,8 +340,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 pb-20"> <div className="space-y-2 pb-20">
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => ( {Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
<div <div
key={folder} key={folder}
className={`border rounded-md transition-all duration-200 ${ className={`border rounded-md transition-all duration-200 ${
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : '' 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"> <div className="flex items-center gap-2 flex-1">
<Folder className="h-4 w-4"/> <Folder className="h-4 w-4"/>
{editingFolder === folder ? ( {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 <Input
value={editingFolderName} value={editingFolderName}
onChange={(e) => setEditingFolderName(e.target.value)} onChange={(e) => setEditingFolderName(e.target.value)}
@@ -395,8 +397,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
</div> </div>
) : ( ) : (
<> <>
<span <span
className="font-medium cursor-pointer hover:text-blue-400 transition-colors" className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (folder !== t('credentials.uncategorized')) { 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" className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
disabled={operationLoading} disabled={operationLoading}
> >
<FolderMinus className="h-3 w-3"/> <FolderMinus
className="h-3 w-3"/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Remove from folder "{credential.folder}"</p> <p>Remove from folder
"{credential.folder}"</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
@@ -538,7 +542,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
)} )}
<div className="flex flex-wrap gap-1"> <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' ? ( {credential.authType === 'password' ? (
<Key className="h-2 w-2 mr-0.5"/> <Key className="h-2 w-2 mr-0.5"/>
) : ( ) : (
@@ -547,7 +552,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
{credential.authType} {credential.authType}
</Badge> </Badge>
{credential.authType === 'key' && credential.keyType && ( {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} {credential.keyType}
</Badge> </Badge>
)} )}
@@ -558,7 +564,8 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
<TooltipContent> <TooltipContent>
<div className="text-center"> <div className="text-center">
<p className="font-medium">Click to edit credential</p> <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> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { FileManagerTabList } from "./FileManagerTabList.tsx"; import {FileManagerTabList} from "./FileManagerTabList.tsx";
interface FileManagerTopNavbarProps { interface FileManagerTopNavbarProps {
tabs: {id: string | number, title: string}[]; tabs: { id: string | number, title: string }[];
activeTab: string | number; activeTab: string | number;
setActiveTab: (tab: string | number) => void; setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void; closeTab: (tab: string | number) => void;
@@ -10,8 +10,8 @@ interface FileManagerTopNavbarProps {
} }
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement { export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props; const {tabs, activeTab, setActiveTab, closeTab, onHomeClick} = props;
return ( return (
<FileManagerTabList <FileManagerTabList
tabs={tabs} tabs={tabs}

View File

@@ -1,6 +1,5 @@
import React, {useState, useEffect, useRef} from "react"; import React, {useState, useEffect, useRef} from "react";
import {FileManagerLeftSidebar} from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx"; 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 {FileManagerHomeView} from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
import {FileManagerFileEditor} from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx"; import {FileManagerFileEditor} from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
import {FileManagerOperations} from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.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 {FIleManagerTopNavbar} from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
import {cn} from '@/lib/utils.ts'; import {cn} from '@/lib/utils.ts';
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react'; import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
import {Separator} from '@/components/ui/separator.tsx';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import { import {
@@ -26,9 +24,9 @@ import {
getSSHStatus, getSSHStatus,
connectSSH connectSSH
} from '@/ui/main-axios.ts'; } 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, onSelectView?: (view: string) => void,
embedded?: boolean, embedded?: boolean,
initialHost?: SSHHost | null, initialHost?: SSHHost | null,
@@ -122,10 +120,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
type: 'directory' type: 'directory'
}))); })));
} catch (err: any) { } catch (err: any) {
console.error('Failed to fetch home data:', err);
const {toast} = await import('sonner'); const {toast} = await import('sonner');
toast.error(t('fileManager.failedToFetchHomeData')); toast.error(t('fileManager.failedToFetchHomeData'));
// Close the file manager tab on connection failure
if (onClose) { if (onClose) {
onClose(); onClose();
} }
@@ -371,7 +368,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
loading: false loading: false
} : t)); } : t));
// Handle toast notification from backend
if (result?.toast) { if (result?.toast) {
toast[result.toast.type](result.toast.message); toast[result.toast.type](result.toast.message);
} else { } else {
@@ -389,7 +385,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
hostId: currentHost.id hostId: currentHost.id
}); });
} catch (recentErr) { } catch (recentErr) {
console.error('Failed to add recent file:', recentErr);
} }
})(), })(),
]).then(() => { ]).then(() => {
@@ -443,14 +438,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
try { try {
const {deleteSSHItem} = await import('@/ui/main-axios.ts'); const {deleteSSHItem} = await import('@/ui/main-axios.ts');
const response = await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory'); const response = await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
// Handle toast notification from backend
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`); toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`);
} }
setDeletingItem(null); setDeletingItem(null);
handleOperationComplete(); handleOperationComplete();
} catch (error: any) { } catch (error: any) {
@@ -475,7 +469,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
onPathChange={updateCurrentPath} onPathChange={updateCurrentPath}
/> />
</div> </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"> <div className="text-center">
<h2 className="text-xl font-semibold text-white mb-2">{t('fileManager.connectToServer')}</h2> <h2 className="text-xl font-semibold text-white mb-2">{t('fileManager.connectToServer')}</h2>
<p className="text-muted-foreground">{t('fileManager.selectServerToEdit')}</p> <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>
</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 h-full">
<div className="flex-1"> <div className="flex-1">
{activeTab === 'home' ? ( {activeTab === 'home' ? (
@@ -605,7 +601,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null,
{t('fileManager.confirmDelete')} {t('fileManager.confirmDelete')}
</h3> </h3>
<p className="text-white mb-4"> <p className="text-white mb-4">
{t('fileManager.confirmDeleteMessage', { name: deletingItem.name })} {t('fileManager.confirmDeleteMessage', {name: deletingItem.name})}
{deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`} {deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`}
</p> </p>
<p className="text-red-400 text-sm mb-6"> <p className="text-red-400 text-sm mb-6">

View File

@@ -1,4 +1,4 @@
import React, {useState, useEffect} from "react"; import React, {useEffect} from "react";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import {loadLanguage} from '@uiw/codemirror-extensions-langs'; import {loadLanguage} from '@uiw/codemirror-extensions-langs';
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link'; import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';

View File

@@ -5,7 +5,7 @@ import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx
import {Input} from '@/components/ui/input.tsx'; import {Input} from '@/components/ui/input.tsx';
import {useState} from 'react'; import {useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import type { FileItem, ShortcutItem } from '../../../types/index'; import type {FileItem, ShortcutItem} from '../../../types/index';
interface FileManagerHomeViewProps { interface FileManagerHomeViewProps {
recent: FileItem[]; recent: FileItem[];
@@ -111,9 +111,12 @@ export function FileManagerHomeView({
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest"> <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"> <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"> <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="recent"
<TabsTrigger value="pinned" className="data-[state=active]:bg-dark-bg-button">{t('fileManager.pinned')}</TabsTrigger> className="data-[state=active]:bg-dark-bg-button">{t('fileManager.recent')}</TabsTrigger>
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-dark-bg-button">{t('fileManager.folderShortcuts')}</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> </TabsList>
<TabsContent value="recent" className="mt-0"> <TabsContent value="recent" className="mt-0">

View File

@@ -1,6 +1,5 @@
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
import {Separator} from '@/components/ui/separator.tsx'; import {Folder, File, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react';
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx'; import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import {cn} from '@/lib/utils.ts'; import {cn} from '@/lib/utils.ts';
import {Input} from '@/components/ui/input.tsx'; import {Input} from '@/components/ui/input.tsx';
@@ -11,18 +10,16 @@ import {
listSSHFiles, listSSHFiles,
renameSSHItem, renameSSHItem,
deleteSSHItem, deleteSSHItem,
getFileManagerRecent,
getFileManagerPinned, getFileManagerPinned,
addFileManagerPinned, addFileManagerPinned,
removeFileManagerPinned, removeFileManagerPinned,
readSSHFile,
getSSHStatus, getSSHStatus,
connectSSH connectSSH
} from '@/ui/main-axios.ts'; } 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( const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: { {onOpenFile, tabs, host, onOperationComplete, onPathChange, onDeleteItem}: {
onSelectView?: (view: string) => void; onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void; onOpenFile: (file: any) => void;
tabs: any[]; tabs: any[];
@@ -55,7 +52,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
const [sshSessionId, setSshSessionId] = useState<string | null>(null); const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false); const [filesLoading, setFilesLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [connectingSSH, setConnectingSSH] = useState(false); const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<Record<string, { const [connectionCache, setConnectionCache] = useState<Record<string, {
sessionId: string; sessionId: string;
@@ -287,7 +283,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
if (y < 0) { if (y < 0) {
y = 0; y = 0;
} }
setContextMenu({ setContextMenu({
visible: true, visible: true,
x, x,
@@ -297,7 +293,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}; };
const closeContextMenu = () => { 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) => { 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) => { const startRename = (item: any) => {
setRenamingItem({ item, newName: item.name }); setRenamingItem({item, newName: item.name});
closeContextMenu(); closeContextMenu();
}; };
@@ -360,10 +340,12 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
return ( return (
<div className="flex flex-col h-full w-[256px] max-w-[256px]"> <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 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 && ( {host && (
<div className="flex flex-col h-full w-full max-w-[260px]"> <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 <Button
size="icon" size="icon"
variant="outline" variant="outline"
@@ -405,14 +387,15 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{connectingSSH || filesLoading ? ( {connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">{t('common.loading')}</div> <div className="text-xs text-muted-foreground">{t('common.loading')}</div>
) : filteredFiles.length === 0 ? ( ) : 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"> <div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => { {filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path); const isOpen = (tabs || []).some((t: any) => t.id === item.path);
const isRenaming = renamingItem?.item?.path === item.path; const isRenaming = renamingItem?.item?.path === item.path;
const isDeleting = false; const isDeleting = false;
return ( return (
<div <div
key={item.path} key={item.path}
@@ -425,18 +408,23 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{isRenaming ? ( {isRenaming ? (
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === 'directory' ? {item.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> : <Folder
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>} className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<File
className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
<Input <Input
value={renamingItem.newName} 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" className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleRename(item, renamingItem.newName); handleRename(item, renamingItem.newName);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setRenamingItem(null); setRenamingItem(null);
} }
}} }}
onBlur={() => handleRename(item, renamingItem.newName)} onBlur={() => handleRename(item, renamingItem.newName)}
@@ -454,13 +442,17 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}))} }))}
> >
{item.type === 'directory' ? {item.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> : <Folder
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>} className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<span className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span> <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>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{item.type === 'file' && ( {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} disabled={isOpen}
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -474,7 +466,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
sshSessionId: host?.id.toString() sshSessionId: host?.id.toString()
}); });
setFiles(files.map(f => setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: false } : f f.path === item.path ? {
...f,
isPinned: false
} : f
)); ));
} else { } else {
await addFileManagerPinned({ await addFileManagerPinned({
@@ -485,14 +480,18 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
sshSessionId: host?.id.toString() sshSessionId: host?.id.toString()
}); });
setFiles(files.map(f => setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: true } : f f.path === item.path ? {
...f,
isPinned: true
} : f
)); ));
} }
} catch (err) { } 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> </Button>
)} )}
{!isOpen && ( {!isOpen && (
@@ -505,7 +504,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
handleContextMenu(e, item); handleContextMenu(e, item);
}} }}
> >
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-4 h-4"/>
</Button> </Button>
)} )}
</div> </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" 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)} onClick={() => startRename(contextMenu.item)}
> >
<Edit3 className="w-4 h-4" /> <Edit3 className="w-4 h-4"/>
Rename Rename
</button> </button>
<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" 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)} onClick={() => startDelete(contextMenu.item)}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4"/>
Delete Delete
</button> </button>
</div> </div>

View File

@@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import {Button} from '@/components/ui/button.tsx'; import {Button} from '@/components/ui/button.tsx';
import {Card} from '@/components/ui/card.tsx'; import {Card} from '@/components/ui/card.tsx';
import {Separator} from '@/components/ui/separator.tsx'; import {Folder, File, Trash2, Pin} from 'lucide-react';
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
interface SSHConnection { interface SSHConnection {
@@ -43,12 +42,6 @@ interface FileManagerLeftSidebarVileViewerProps {
} }
export function FileManagerLeftSidebarFileViewer({ export function FileManagerLeftSidebarFileViewer({
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath, currentPath,
files, files,
onOpenFile, onOpenFile,
@@ -58,12 +51,9 @@ export function FileManagerLeftSidebarFileViewer({
isLoading, isLoading,
error, error,
isSSHMode, isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: FileManagerLeftSidebarVileViewerProps) { }: FileManagerLeftSidebarVileViewerProps) {
const {t} = useTranslation(); const {t} = useTranslation();
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto"> <div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">

View File

@@ -10,14 +10,13 @@ import {
Trash2, Trash2,
Edit3, Edit3,
X, X,
Check,
AlertCircle, AlertCircle,
FileText, FileText,
Folder Folder
} from 'lucide-react'; } from 'lucide-react';
import {cn} from '@/lib/utils.ts'; import {cn} from '@/lib/utils.ts';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import type { FileManagerOperationsProps } from '../../../types/index.js'; import type {FileManagerOperationsProps} from '../../../types/index.js';
export function FileManagerOperations({ export function FileManagerOperations({
currentPath, currentPath,
@@ -56,7 +55,7 @@ export function FileManagerOperations({
}; };
checkContainerWidth(); checkContainerWidth();
const resizeObserver = new ResizeObserver(checkContainerWidth); const resizeObserver = new ResizeObserver(checkContainerWidth);
if (containerRef.current) { if (containerRef.current) {
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
@@ -71,32 +70,28 @@ export function FileManagerOperations({
if (!uploadFile || !sshSessionId) return; if (!uploadFile || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
// Show loading toast
const {toast} = await import('sonner'); 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 { try {
const content = await uploadFile.text(); const content = await uploadFile.text();
const {uploadSSHFile} = await import('@/ui/main-axios.ts'); const {uploadSSHFile} = await import('@/ui/main-axios.ts');
const response = await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content); const response = await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
// Dismiss loading toast and show success
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
// Handle toast notification from backend
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name })); onSuccess(t('fileManager.fileUploadedSuccessfully', {name: uploadFile.name}));
} }
setShowUpload(false); setShowUpload(false);
setUploadFile(null); setUploadFile(null);
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
// Dismiss loading toast and show error
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile')); onError(error?.response?.data?.error || t('fileManager.failedToUploadFile'));
} finally { } finally {
@@ -108,31 +103,27 @@ export function FileManagerOperations({
if (!newFileName.trim() || !sshSessionId) return; if (!newFileName.trim() || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
// Show loading toast
const {toast} = await import('sonner'); const {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 { try {
const {createSSHFile} = await import('@/ui/main-axios.ts'); const {createSSHFile} = await import('@/ui/main-axios.ts');
const response = await createSSHFile(sshSessionId, currentPath, newFileName.trim()); const response = await createSSHFile(sshSessionId, currentPath, newFileName.trim());
// Dismiss loading toast
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
// Handle toast notification from backend
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() })); onSuccess(t('fileManager.fileCreatedSuccessfully', {name: newFileName.trim()}));
} }
setShowCreateFile(false); setShowCreateFile(false);
setNewFileName(''); setNewFileName('');
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
// Dismiss loading toast and show error
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile')); onError(error?.response?.data?.error || t('fileManager.failedToCreateFile'));
} finally { } finally {
@@ -144,31 +135,27 @@ export function FileManagerOperations({
if (!newFolderName.trim() || !sshSessionId) return; if (!newFolderName.trim() || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
// Show loading toast
const {toast} = await import('sonner'); const {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 { try {
const {createSSHFolder} = await import('@/ui/main-axios.ts'); const {createSSHFolder} = await import('@/ui/main-axios.ts');
const response = await createSSHFolder(sshSessionId, currentPath, newFolderName.trim()); const response = await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
// Dismiss loading toast
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
// Handle toast notification from backend
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() })); onSuccess(t('fileManager.folderCreatedSuccessfully', {name: newFolderName.trim()}));
} }
setShowCreateFolder(false); setShowCreateFolder(false);
setNewFolderName(''); setNewFolderName('');
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
// Dismiss loading toast and show error
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder')); onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder'));
} finally { } finally {
@@ -180,35 +167,31 @@ export function FileManagerOperations({
if (!deletePath || !sshSessionId) return; if (!deletePath || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
// Show loading toast
const {toast} = await import('sonner'); 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'), type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file'),
name: deletePath.split('/').pop() name: deletePath.split('/').pop()
})); }));
try { try {
const {deleteSSHItem} = await import('@/ui/main-axios.ts'); const {deleteSSHItem} = await import('@/ui/main-axios.ts');
const response = await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory); const response = await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
// Dismiss loading toast
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
// Handle toast notification from backend
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } 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); setShowDelete(false);
setDeletePath(''); setDeletePath('');
setDeleteIsDirectory(false); setDeleteIsDirectory(false);
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
// Dismiss loading toast and show error
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem')); onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
} finally { } finally {
@@ -220,37 +203,33 @@ export function FileManagerOperations({
if (!renamePath || !newName.trim() || !sshSessionId) return; if (!renamePath || !newName.trim() || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
// Show loading toast
const {toast} = await import('sonner'); const {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'), type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file'),
oldName: renamePath.split('/').pop(), oldName: renamePath.split('/').pop(),
newName: newName.trim() newName: newName.trim()
})); }));
try { try {
const {renameSSHItem} = await import('@/ui/main-axios.ts'); const {renameSSHItem} = await import('@/ui/main-axios.ts');
const response = await renameSSHItem(sshSessionId, renamePath, newName.trim()); const response = await renameSSHItem(sshSessionId, renamePath, newName.trim());
// Dismiss loading toast
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
// Handle toast notification from backend
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } 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); setShowRename(false);
setRenamePath(''); setRenamePath('');
setRenameIsDirectory(false); setRenameIsDirectory(false);
setNewName(''); setNewName('');
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
// Dismiss loading toast and show error
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem')); onError(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
} finally { } 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="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-start gap-2 text-red-300"> <div className="flex items-start gap-2 text-red-300">
<AlertCircle className="w-5 h-5 flex-shrink-0"/> <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>
</div> </div>

View File

@@ -7,7 +7,7 @@ import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManag
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx"; import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import type { SSHHost, HostManagerProps } from '../../../types/index'; import type {SSHHost, HostManagerProps} from '../../../types/index';
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement { export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -40,7 +40,6 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
// Reset editing states when switching away from edit tabs
if (value !== "add_host") { if (value !== "add_host") {
setEditingHost(null); 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"> <TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/> <Separator className="p-0.25 -mt-0.5 mb-1"/>
<div className="flex flex-col h-full min-h-0 overflow-auto"> <div className="flex flex-col h-full min-h-0 overflow-auto">
<CredentialsManager onEditCredential={handleEditCredential} /> <CredentialsManager onEditCredential={handleEditCredential}/>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0">

View File

@@ -3,15 +3,7 @@ import {Controller, useForm} from "react-hook-form"
import {z} from "zod" import {z} from "zod"
import {Button} from "@/components/ui/button.tsx" import {Button} from "@/components/ui/button.tsx"
import { import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel,} from "@/components/ui/form.tsx";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx";
import {Input} from "@/components/ui/input.tsx"; import {Input} from "@/components/ui/input.tsx";
import {PasswordInput} from "@/components/ui/password-input.tsx"; import {PasswordInput} from "@/components/ui/password-input.tsx";
import {ScrollArea} from "@/components/ui/scroll-area.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 {Switch} from "@/components/ui/switch.tsx";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx"; import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
import {toast} from "sonner"; import {toast} from "sonner";
import {createSSHHost, updateSSHHost, getSSHHosts, getCredentials} from '@/ui/main-axios.ts'; import {createSSHHost, getCredentials, getSSHHosts, updateSSHHost} from '@/ui/main-axios.ts';
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {CredentialSelector} from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx"; 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 [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload'); const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload');
const isSubmittingRef = useRef(false); const isSubmittingRef = useRef(false);
// Ref for the IP address input to manage focus
const ipInputRef = useRef<HTMLInputElement>(null); const ipInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@@ -103,7 +94,6 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
fetchData(); fetchData();
}, []); }, []);
// Listen for credential changes to refresh the credential list
useEffect(() => { useEffect(() => {
const handleCredentialChange = async () => { const handleCredentialChange = async () => {
try { try {
@@ -126,14 +116,13 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
setFolders(uniqueFolders); setFolders(uniqueFolders);
setSshConfigurations(uniqueConfigurations); setSshConfigurations(uniqueConfigurations);
} catch (error) { } catch (error) {
// Handle error silently
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
window.addEventListener('credentials:changed', handleCredentialChange); window.addEventListener('credentials:changed', handleCredentialChange);
return () => { return () => {
window.removeEventListener('credentials:changed', handleCredentialChange); 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(() => { useEffect(() => {
if (authTab === 'credential') { if (authTab === 'credential') {
const currentCredentialId = form.getValues('credentialId'); const currentCredentialId = form.getValues('credentialId');
@@ -262,7 +250,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
useEffect(() => { useEffect(() => {
if (editingHost) { if (editingHost) {
const cleanedHost = { ...editingHost }; const cleanedHost = {...editingHost};
if (cleanedHost.credentialId && cleanedHost.key) { if (cleanedHost.credentialId && cleanedHost.key) {
cleanedHost.key = undefined; cleanedHost.key = undefined;
cleanedHost.keyPassword = undefined; cleanedHost.keyPassword = undefined;
@@ -272,10 +260,10 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
} else if (cleanedHost.key && cleanedHost.password) { } else if (cleanedHost.key && cleanedHost.password) {
cleanedHost.password = undefined; cleanedHost.password = undefined;
} }
const defaultAuthType = cleanedHost.credentialId ? 'credential' : (cleanedHost.key ? 'key' : 'password'); const defaultAuthType = cleanedHost.credentialId ? 'credential' : (cleanedHost.key ? 'key' : 'password');
setAuthTab(defaultAuthType); setAuthTab(defaultAuthType);
const formData = { const formData = {
name: cleanedHost.name || "", name: cleanedHost.name || "",
ip: cleanedHost.ip || "", ip: cleanedHost.ip || "",
@@ -296,12 +284,11 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
defaultPath: cleanedHost.defaultPath || "/", defaultPath: cleanedHost.defaultPath || "/",
tunnelConnections: cleanedHost.tunnelConnections || [], tunnelConnections: cleanedHost.tunnelConnections || [],
}; };
// Only set the relevant authentication fields based on authType
if (defaultAuthType === 'password') { if (defaultAuthType === 'password') {
formData.password = cleanedHost.password || ""; formData.password = cleanedHost.password || "";
} else if (defaultAuthType === 'key') { } else if (defaultAuthType === 'key') {
formData.key = "existing_key"; // Placeholder to indicate existing key formData.key = "existing_key";
formData.keyPassword = cleanedHost.keyPassword || ""; formData.keyPassword = cleanedHost.keyPassword || "";
formData.keyType = (cleanedHost.keyType as any) || "auto"; formData.keyType = (cleanedHost.keyType as any) || "auto";
} else if (defaultAuthType === 'credential') { } else if (defaultAuthType === 'credential') {
@@ -349,7 +336,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
try { try {
isSubmittingRef.current = true; isSubmittingRef.current = true;
if (!data.name || data.name.trim() === '') { if (!data.name || data.name.trim() === '') {
data.name = `${data.username}@${data.ip}`; data.name = `${data.username}@${data.ip}`;
} }
@@ -399,23 +386,22 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
if (editingHost) { if (editingHost) {
const updatedHost = await updateSSHHost(editingHost.id, submitData); 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) { if (onFormSubmit) {
onFormSubmit(updatedHost); onFormSubmit(updatedHost);
} }
} else { } else {
const newHost = await createSSHHost(submitData); const newHost = await createSSHHost(submitData);
toast.success(t('hosts.hostAddedSuccessfully', { name: data.name })); toast.success(t('hosts.hostAddedSuccessfully', {name: data.name}));
if (onFormSubmit) { if (onFormSubmit) {
onFormSubmit(newHost); onFormSubmit(newHost);
} }
} }
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
// Reset form after successful submission
form.reset(); form.reset();
} catch (error) { } catch (error) {
toast.error(t('hosts.failedToSaveHost')); toast.error(t('hosts.failedToSaveHost'));
@@ -574,8 +560,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
<FormItem className="col-span-5"> <FormItem className="col-span-5">
<FormLabel>{t('hosts.ipAddress')}</FormLabel> <FormLabel>{t('hosts.ipAddress')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={t('placeholders.ipAddress')} placeholder={t('placeholders.ipAddress')}
{...field} {...field}
ref={(e) => { ref={(e) => {
field.ref(e); field.ref(e);
@@ -745,8 +731,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
const newAuthType = value as 'password' | 'key' | 'credential'; const newAuthType = value as 'password' | 'key' | 'credential';
setAuthTab(newAuthType); setAuthTab(newAuthType);
form.setValue('authType', newAuthType); form.setValue('authType', newAuthType);
// Clear authentication fields based on what we're switching away from
if (newAuthType === 'password') { if (newAuthType === 'password') {
form.setValue('key', null); form.setValue('key', null);
form.setValue('keyPassword', ''); form.setValue('keyPassword', '');
@@ -773,11 +758,12 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
<FormField <FormField
control={form.control} control={form.control}
name="password" name="password"
render={({ field }) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>{t('hosts.password')}</FormLabel> <FormLabel>{t('hosts.password')}</FormLabel>
<FormControl> <FormControl>
<PasswordInput placeholder={t('placeholders.password')} {...field} /> <PasswordInput
placeholder={t('placeholders.password')} {...field} />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -788,7 +774,6 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
value={keyInputMethod} value={keyInputMethod}
onValueChange={(value) => { onValueChange={(value) => {
setKeyInputMethod(value as 'upload' | 'paste'); setKeyInputMethod(value as 'upload' | 'paste');
// Clear the other field when switching
if (value === 'upload') { if (value === 'upload') {
form.setValue('key', null); form.setValue('key', null);
} else { } else {
@@ -797,7 +782,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
}} }}
className="w-full" 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="upload">{t('hosts.uploadFile')}</TabsTrigger>
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger> <TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
</TabsList> </TabsList>
@@ -827,8 +813,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
> >
<span className="truncate" <span className="truncate"
title={field.value?.name || t('hosts.upload')}> title={field.value?.name || t('hosts.upload')}>
{field.value === "existing_key" ? t('hosts.existingKey') : {field.value === "existing_key" ? t('hosts.existingKey') :
field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')} field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
</span> </span>
</Button> </Button>
</div> </div>
@@ -856,7 +842,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
)} )}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<div className="grid grid-cols-15 gap-4 mt-4"> <div className="grid grid-cols-15 gap-4 mt-4">
<FormField <FormField
control={form.control} control={form.control}
@@ -925,14 +911,13 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
<FormField <FormField
control={form.control} control={form.control}
name="credentialId" name="credentialId"
render={({ field }) => ( render={({field}) => (
<FormItem> <FormItem>
<CredentialSelector <CredentialSelector
value={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={field.onChange}
onCredentialSelect={(credential) => { onCredentialSelect={(credential) => {
if (credential) { if (credential) {
// Update username when credential is selected
form.setValue('username', credential.username); form.setValue('username', credential.username);
} }
}} }}
@@ -1002,7 +987,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
sshpass</code> or <code sshpass</code> or <code
className="bg-muted px-1 rounded inline">sudo dnf install className="bg-muted px-1 rounded inline">sudo dnf install
sshpass</code></div> 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> install hudochenkov/sshpass/sshpass</code></div>
<div> {t('hosts.windows')}</div> <div> {t('hosts.windows')}</div>
</div> </div>
@@ -1026,9 +1012,9 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="mt-3 flex justify-between"> <div className="mt-3 flex justify-between">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 px-3 text-xs" className="h-8 px-3 text-xs"
onClick={() => window.open('https://docs.termix.site/tunnels', '_blank')} onClick={() => window.open('https://docs.termix.site/tunnels', '_blank')}
> >
@@ -1148,7 +1134,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
</div> </div>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
{t('hosts.tunnelForwardDescription', { {t('hosts.tunnelForwardDescription', {
sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22', sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22',
endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224' endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224'
})} })}

View File

@@ -1,5 +1,4 @@
import React, {useState, useEffect, useMemo, useRef} from "react"; import React, {useState, useEffect, useMemo, useRef} from "react";
import {Card, CardContent} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {Badge} from "@/components/ui/badge.tsx"; import {Badge} from "@/components/ui/badge.tsx";
import {ScrollArea} from "@/components/ui/scroll-area.tsx"; import {ScrollArea} from "@/components/ui/scroll-area.tsx";
@@ -22,14 +21,12 @@ import {
FileEdit, FileEdit,
Search, Search,
Upload, Upload,
Info,
X, X,
Check, Check,
Pencil, Pencil,
FolderMinus FolderMinus
} from "lucide-react"; } 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) { export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -48,16 +45,15 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
useEffect(() => { useEffect(() => {
fetchHosts(); fetchHosts();
// Listen for refresh events from other components
const handleHostsRefresh = () => { const handleHostsRefresh = () => {
fetchHosts(); fetchHosts();
}; };
window.addEventListener('hosts:refresh', handleHostsRefresh); window.addEventListener('hosts:refresh', handleHostsRefresh);
window.addEventListener('ssh-hosts:changed', handleHostsRefresh); window.addEventListener('ssh-hosts:changed', handleHostsRefresh);
window.addEventListener('folders:changed', handleHostsRefresh); window.addEventListener('folders:changed', handleHostsRefresh);
return () => { return () => {
window.removeEventListener('hosts:refresh', handleHostsRefresh); window.removeEventListener('hosts:refresh', handleHostsRefresh);
window.removeEventListener('ssh-hosts:changed', handleHostsRefresh); window.removeEventListener('ssh-hosts:changed', handleHostsRefresh);
@@ -69,9 +65,9 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
try { try {
setLoading(true); setLoading(true);
const data = await getSSHHosts(); const data = await getSSHHosts();
const cleanedHosts = data.map(host => { const cleanedHosts = data.map(host => {
const cleanedHost = { ...host }; const cleanedHost = {...host};
if (cleanedHost.credentialId && cleanedHost.key) { if (cleanedHost.credentialId && cleanedHost.key) {
cleanedHost.key = undefined; cleanedHost.key = undefined;
cleanedHost.keyPassword = undefined; cleanedHost.keyPassword = undefined;
@@ -86,7 +82,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
} }
return cleanedHost; return cleanedHost;
}); });
setHosts(cleanedHosts); setHosts(cleanedHosts);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -98,11 +94,11 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const handleDelete = async (hostId: number, hostName: string) => { const handleDelete = async (hostId: number, hostName: string) => {
confirmWithToast( confirmWithToast(
t('hosts.confirmDelete', { name: hostName }), t('hosts.confirmDelete', {name: hostName}),
async () => { async () => {
try { try {
await deleteSSHHost(hostId); await deleteSSHHost(hostId);
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName })); toast.success(t('hosts.hostDeletedSuccessfully', {name: hostName}));
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) { } catch (err) {
@@ -115,41 +111,38 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const handleExport = (host: SSHHost) => { const handleExport = (host: SSHHost) => {
const actualAuthType = host.credentialId ? 'credential' : (host.key ? 'key' : 'password'); const actualAuthType = host.credentialId ? 'credential' : (host.key ? 'key' : 'password');
// Check if host uses sensitive authentication data
if (actualAuthType === 'credential') { if (actualAuthType === 'credential') {
const confirmMessage = t('hosts.exportCredentialWarning', { const confirmMessage = t('hosts.exportCredentialWarning', {
name: host.name || `${host.username}@${host.ip}` name: host.name || `${host.username}@${host.ip}`
}); });
confirmWithToast(confirmMessage, () => { confirmWithToast(confirmMessage, () => {
performExport(host, actualAuthType); performExport(host, actualAuthType);
}); });
return; return;
} else if (actualAuthType === 'password' || actualAuthType === 'key') { } else if (actualAuthType === 'password' || actualAuthType === 'key') {
const confirmMessage = t('hosts.exportSensitiveDataWarning', { const confirmMessage = t('hosts.exportSensitiveDataWarning', {
name: host.name || `${host.username}@${host.ip}` name: host.name || `${host.username}@${host.ip}`
}); });
confirmWithToast(confirmMessage, () => { confirmWithToast(confirmMessage, () => {
performExport(host, actualAuthType); performExport(host, actualAuthType);
}); });
return; return;
} }
// No sensitive data, proceed directly
performExport(host, actualAuthType); performExport(host, actualAuthType);
}; };
const performExport = (host: SSHHost, actualAuthType: string) => { const performExport = (host: SSHHost, actualAuthType: string) => {
// Create export data with sensitive fields excluded
const exportData: any = { const exportData: any = {
name: host.name, name: host.name,
ip: host.ip, ip: host.ip,
port: host.port, port: host.port,
username: host.username, username: host.username,
authType: actualAuthType, // Use the determined authType, not the stored one authType: actualAuthType,
folder: host.folder, folder: host.folder,
tags: host.tags, tags: host.tags,
pin: host.pin, pin: host.pin,
@@ -160,18 +153,16 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
tunnelConnections: host.tunnelConnections, tunnelConnections: host.tunnelConnections,
}; };
// Only include credentialId if actualAuthType is credential, but set it to null for security
if (actualAuthType === 'credential') { 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( const cleanExportData = Object.fromEntries(
Object.entries(exportData).filter(([_, value]) => value !== undefined) 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 url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -193,13 +184,13 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const handleRemoveFromFolder = async (host: SSHHost) => { const handleRemoveFromFolder = async (host: SSHHost) => {
confirmWithToast( 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 () => { async () => {
try { try {
setOperationLoading(true); setOperationLoading(true);
const updatedHost = { ...host, folder: '' }; const updatedHost = {...host, folder: ''};
await updateSSHHost(host.id, updatedHost); 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(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) { } catch (err) {
@@ -221,7 +212,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
try { try {
setOperationLoading(true); setOperationLoading(true);
await renameFolder(oldName, editingFolderName.trim()); 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(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
setEditingFolder(null); setEditingFolder(null);
@@ -243,11 +234,10 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
setEditingFolderName(''); setEditingFolderName('');
}; };
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, host: SSHHost) => { const handleDragStart = (e: React.DragEvent, host: SSHHost) => {
setDraggedHost(host); setDraggedHost(host);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // Required for Firefox e.dataTransfer.setData('text/plain', '');
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
@@ -282,7 +272,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
if (!draggedHost) return; if (!draggedHost) return;
const newFolder = targetFolder === t('hosts.uncategorized') ? '' : targetFolder; const newFolder = targetFolder === t('hosts.uncategorized') ? '' : targetFolder;
if (draggedHost.folder === newFolder) { if (draggedHost.folder === newFolder) {
setDraggedHost(null); setDraggedHost(null);
return; return;
@@ -290,11 +280,11 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
try { try {
setOperationLoading(true); setOperationLoading(true);
const updatedHost = { ...draggedHost, folder: newFolder }; const updatedHost = {...draggedHost, folder: newFolder};
await updateSSHHost(draggedHost.id, updatedHost); await updateSSHHost(draggedHost.id, updatedHost);
toast.success(t('hosts.movedToFolder', { toast.success(t('hosts.movedToFolder', {
name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`, name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`,
folder: targetFolder folder: targetFolder
})); }));
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
@@ -332,7 +322,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const result = await bulkImportSSHHosts(hostsArray); const result = await bulkImportSSHHosts(hostsArray);
if (result.success > 0) { 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) { if (result.errors.length > 0) {
toast.error(`Import errors: ${result.errors.join(', ')}`); toast.error(`Import errors: ${result.errors.join(', ')}`);
} }
@@ -436,7 +426,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div> <div>
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2> <h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('hosts.hostsCount', { count: 0 })} {t('hosts.hostsCount', {count: 0})}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -469,66 +459,66 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const sampleData = { const sampleData = {
hosts: [ hosts: [
{
name: "Web Server - Production",
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/var/www"
},
{
name: "Database Server",
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
tunnelConnections: [
{ {
sourcePort: 5432, name: "Web Server - Production",
endpointPort: 5432, ip: "192.168.1.100",
endpointHost: "Web Server - Production", port: 22,
maxRetries: 3, username: "admin",
retryInterval: 10, authType: "password",
autoStart: true 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 blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -590,7 +580,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div> <div>
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2> <h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('hosts.hostsCount', { count: filteredAndSortedHosts.length })} {t('hosts.hostsCount', {count: filteredAndSortedHosts.length})}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -738,8 +728,8 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 pb-20"> <div className="space-y-2 pb-20">
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => ( {Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
<div <div
key={folder} key={folder}
className={`border rounded-md transition-all duration-200 ${ className={`border rounded-md transition-all duration-200 ${
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : '' 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"> <div className="flex items-center gap-2 flex-1">
<Folder className="h-4 w-4"/> <Folder className="h-4 w-4"/>
{editingFolder === folder ? ( {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 <Input
value={editingFolderName} value={editingFolderName}
onChange={(e) => setEditingFolderName(e.target.value)} onChange={(e) => setEditingFolderName(e.target.value)}
@@ -794,8 +785,8 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
</div> </div>
) : ( ) : (
<> <>
<span <span
className="font-medium cursor-pointer hover:text-blue-400 transition-colors" className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (folder !== t('hosts.uncategorized')) { if (folder !== t('hosts.uncategorized')) {
@@ -851,143 +842,149 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
{host.name || `${host.username}@${host.ip}`} {host.name || `${host.username}@${host.ip}`}
</h3> </h3>
</div> </div>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.ip}:{host.port}
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{host.username} {host.username}
</p> </p>
</div> </div>
<div className="flex gap-1 flex-shrink-0 ml-1"> <div className="flex gap-1 flex-shrink-0 ml-1">
{host.folder && host.folder !== '' && ( {host.folder && host.folder !== '' && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleRemoveFromFolder(host); handleRemoveFromFolder(host);
}} }}
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10" className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
disabled={operationLoading} disabled={operationLoading}
> >
<FolderMinus className="h-3 w-3"/> <FolderMinus
</Button> className="h-3 w-3"/>
</TooltipTrigger> </Button>
<TooltipContent> </TooltipTrigger>
<p>Remove from folder "{host.folder}"</p> <TooltipContent>
</TooltipContent> <p>Remove from folder
</Tooltip> "{host.folder}"</p>
)} </TooltipContent>
<Tooltip> </Tooltip>
<TooltipTrigger asChild> )}
<Button <Tooltip>
size="sm" <TooltipTrigger asChild>
variant="ghost" <Button
onClick={(e) => { size="sm"
e.stopPropagation(); variant="ghost"
handleEdit(host); onClick={(e) => {
}} e.stopPropagation();
className="h-5 w-5 p-0 hover:bg-blue-500/10" handleEdit(host);
> }}
<Edit className="h-3 w-3"/> className="h-5 w-5 p-0 hover:bg-blue-500/10"
</Button> >
</TooltipTrigger> <Edit className="h-3 w-3"/>
<TooltipContent> </Button>
<p>Edit host</p> </TooltipTrigger>
</TooltipContent> <TooltipContent>
</Tooltip> <p>Edit host</p>
<Tooltip> </TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<Button <Tooltip>
size="sm" <TooltipTrigger asChild>
variant="ghost" <Button
onClick={(e) => { size="sm"
e.stopPropagation(); variant="ghost"
handleDelete(host.id, host.name || `${host.username}@${host.ip}`); onClick={(e) => {
}} e.stopPropagation();
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10" handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
> }}
<Trash2 className="h-3 w-3"/> className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
</Button> >
</TooltipTrigger> <Trash2 className="h-3 w-3"/>
<TooltipContent> </Button>
<p>Delete host</p> </TooltipTrigger>
</TooltipContent> <TooltipContent>
</Tooltip> <p>Delete host</p>
<Tooltip> </TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<Button <Tooltip>
size="sm" <TooltipTrigger asChild>
variant="ghost" <Button
onClick={(e) => { size="sm"
e.stopPropagation(); variant="ghost"
handleExport(host); onClick={(e) => {
}} e.stopPropagation();
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10" handleExport(host);
> }}
<Upload className="h-3 w-3"/> className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10"
</Button> >
</TooltipTrigger> <Upload className="h-3 w-3"/>
<TooltipContent> </Button>
<p>Export host</p> </TooltipTrigger>
</TooltipContent> <TooltipContent>
</Tooltip> <p>Export host</p>
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{host.tags && host.tags.length > 0 && ( {host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.tags.slice(0, 6).map((tag, index) => ( {host.tags.slice(0, 6).map((tag, index) => (
<Badge key={index} variant="outline" <Badge key={index} variant="outline"
className="text-xs px-1 py-0"> className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5"/> <Tag className="h-2 w-2 mr-0.5"/>
{tag} {tag}
</Badge> </Badge>
))} ))}
{host.tags.length > 6 && ( {host.tags.length > 6 && (
<Badge variant="outline" <Badge variant="outline"
className="text-xs px-1 py-0"> className="text-xs px-1 py-0">
+{host.tags.length - 6} +{host.tags.length - 6}
</Badge> </Badge>
)} )}
</div> </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>
)} )}
</Badge>
)} <div className="flex flex-wrap gap-1">
{host.enableFileManager && ( {host.enableTerminal && (
<Badge variant="outline" className="text-xs px-1 py-0"> <Badge variant="outline"
<FileEdit className="h-2 w-2 mr-0.5"/> className="text-xs px-1 py-0">
{t('hosts.fileManagerBadge')} <Terminal className="h-2 w-2 mr-0.5"/>
</Badge> {t('hosts.terminalBadge')}
)} </Badge>
</div> )}
</div> {host.enableTunnel && (
</div> <Badge variant="outline"
</TooltipTrigger> 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> <TooltipContent>
<div className="text-center"> <div className="text-center">
<p className="font-medium">Click to edit host</p> <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> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@@ -50,7 +50,6 @@ export function Server({
setCurrentHostConfig(updatedHost); setCurrentHostConfig(updatedHost);
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch latest host config:', error);
toast.error(t('serverStats.failedToFetchHostConfig')); toast.error(t('serverStats.failedToFetchHostConfig'));
} }
} }
@@ -68,7 +67,6 @@ export function Server({
setCurrentHostConfig(updatedHost); setCurrentHostConfig(updatedHost);
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch updated host config:', error);
toast.error(t('serverStats.failedToFetchHostConfig')); toast.error(t('serverStats.failedToFetchHostConfig'));
} }
} }
@@ -89,20 +87,14 @@ export function Server({
setServerStatus(res?.status === 'online' ? 'online' : 'offline'); setServerStatus(res?.status === 'online' ? 'online' : 'offline');
} }
} catch (error: any) { } catch (error: any) {
console.error('Failed to fetch server status:', error);
if (!cancelled) { if (!cancelled) {
// Handle different error types from the new backend
if (error?.response?.status === 503) { if (error?.response?.status === 503) {
// Server is offline
setServerStatus('offline'); setServerStatus('offline');
} else if (error?.response?.status === 504) { } else if (error?.response?.status === 504) {
// Timeout - treat as degraded
setServerStatus('offline'); setServerStatus('offline');
} else if (error?.response?.status === 404) { } else if (error?.response?.status === 404) {
// Host not found
setServerStatus('offline'); setServerStatus('offline');
} else { } else {
// Other errors - treat as offline
setServerStatus('offline'); setServerStatus('offline');
} }
toast.error(t('serverStats.failedToFetchStatus')); toast.error(t('serverStats.failedToFetchStatus'));
@@ -119,7 +111,6 @@ export function Server({
setMetrics(data); setMetrics(data);
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch server metrics:', error);
if (!cancelled) { if (!cancelled) {
setMetrics(null); setMetrics(null);
toast.error(t('serverStats.failedToFetchMetrics')); toast.error(t('serverStats.failedToFetchMetrics'));
@@ -154,8 +145,8 @@ export function Server({
const isFileManagerAlreadyOpen = React.useMemo(() => { const isFileManagerAlreadyOpen = React.useMemo(() => {
if (!currentHostConfig) return false; if (!currentHostConfig) return false;
return tabs.some((tab: any) => return tabs.some((tab: any) =>
tab.type === 'file_manager' && tab.type === 'file_manager' &&
tab.hostConfig?.id === currentHostConfig.id tab.hostConfig?.id === currentHostConfig.id
); );
}, [tabs, currentHostConfig]); }, [tabs, currentHostConfig]);
@@ -204,18 +195,13 @@ export function Server({
const data = await getServerMetricsById(currentHostConfig.id); const data = await getServerMetricsById(currentHostConfig.id);
setMetrics(data); setMetrics(data);
} catch (error: any) { } catch (error: any) {
// Handle different error types from the new backend
if (error?.response?.status === 503) { if (error?.response?.status === 503) {
// Server is offline
setServerStatus('offline'); setServerStatus('offline');
} else if (error?.response?.status === 504) { } else if (error?.response?.status === 504) {
// Timeout - treat as offline
setServerStatus('offline'); setServerStatus('offline');
} else if (error?.response?.status === 404) { } else if (error?.response?.status === 404) {
// Host not found
setServerStatus('offline'); setServerStatus('offline');
} else { } else {
// Other errors - treat as offline
setServerStatus('offline'); setServerStatus('offline');
} }
setMetrics(null); setMetrics(null);
@@ -228,7 +214,8 @@ export function Server({
> >
{isRefreshing ? ( {isRefreshing ? (
<div className="flex items-center gap-2"> <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')} {t('serverStats.refreshing')}
</div> </div>
) : ( ) : (
@@ -265,14 +252,16 @@ export function Server({
{isLoadingMetrics && !metrics ? ( {isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3"> <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> <span className="text-gray-300">{t('serverStats.loadingMetrics')}</span>
</div> </div>
</div> </div>
) : !metrics && serverStatus === 'offline' ? ( ) : !metrics && serverStatus === 'offline' ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="text-center"> <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 className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div> </div>
<p className="text-gray-300 mb-1">{t('serverStats.serverOffline')}</p> <p className="text-gray-300 mb-1">{t('serverStats.serverOffline')}</p>
@@ -281,15 +270,16 @@ export function Server({
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{/* CPU Stats */} {/* 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
<div className="flex items-center gap-2 mb-3"> 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">
<Cpu className="h-5 w-5 text-blue-400" /> <div className="flex items-center gap-2 mb-3">
<h3 className="font-semibold text-lg text-white">{t('serverStats.cpuUsage')}</h3> <Cpu className="h-5 w-5 text-blue-400"/>
</div> <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"> <div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300"> <span className="text-sm text-gray-300">
{(() => { {(() => {
const pct = metrics?.cpu?.percent; const pct = metrics?.cpu?.percent;
@@ -299,33 +289,34 @@ export function Server({
return `${pctText} ${t('serverStats.of')} ${coresText}`; return `${pctText} ${t('serverStats.of')} ${coresText}`;
})()} })()}
</span> </span>
</div> </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>
{/* Memory Stats */} <div className="relative">
<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"> <Progress
<div className="flex items-center gap-2 mb-3"> value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}
<MemoryStick className="h-5 w-5 text-green-400" /> className="h-2"
<h3 className="font-semibold text-lg text-white">{t('serverStats.memoryUsage')}</h3> />
</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>
<div className="space-y-2"> {/* Memory Stats */}
<div className="flex justify-between items-center"> <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"> <span className="text-sm text-gray-300">
{(() => { {(() => {
const pct = metrics?.memory?.percent; const pct = metrics?.memory?.percent;
@@ -337,35 +328,36 @@ export function Server({
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`; return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
})()} })()}
</span> </span>
</div> </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>
{/* Disk Stats */} <div className="relative">
<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"> <Progress
<div className="flex items-center gap-2 mb-3"> value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}
<HardDrive className="h-5 w-5 text-orange-400" /> className="h-2"
<h3 className="font-semibold text-lg text-white">{t('serverStats.rootStorageSpace')}</h3> />
</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>
<div className="space-y-2"> {/* Disk Stats */}
<div className="flex justify-between items-center"> <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"> <span className="text-sm text-gray-300">
{(() => { {(() => {
const pct = metrics?.disk?.percent; const pct = metrics?.disk?.percent;
@@ -377,25 +369,25 @@ export function Server({
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`; return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
})()} })()}
</span> </span>
</div> </div>
<div className="relative"> <div className="relative">
<Progress <Progress
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0} value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}
className="h-2" className="h-2"
/> />
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{(() => { {(() => {
const used = metrics?.disk?.usedHuman; const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman; const total = metrics?.disk?.totalHuman;
return used && total ? `Available: ${total}` : 'Available: N/A'; return used && total ? `Available: ${total}` : 'Available: N/A';
})()} })()}
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
)} )}
</div> </div>

View File

@@ -94,7 +94,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} }
webSocketRef.current?.close(); webSocketRef.current?.close();
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); // Clear connecting state setIsConnecting(false);
}, },
fit: () => { fit: () => {
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
@@ -138,59 +138,48 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} }
function attemptReconnection() { function attemptReconnection() {
// Don't attempt reconnection if component is unmounting, shouldn't reconnect, or already reconnecting
if (isUnmountingRef.current || shouldNotReconnectRef.current || isReconnectingRef.current) { if (isUnmountingRef.current || shouldNotReconnectRef.current || isReconnectingRef.current) {
return; return;
} }
// Check if we've already reached max attempts
if (reconnectAttempts.current >= maxReconnectAttempts) { if (reconnectAttempts.current >= maxReconnectAttempts) {
toast.error(t('terminal.maxReconnectAttemptsReached')); toast.error(t('terminal.maxReconnectAttemptsReached'));
// Close the terminal tab when max attempts reached
if (onClose) { if (onClose) {
onClose(); onClose();
} }
return; return;
} }
// Set reconnecting flag to prevent multiple simultaneous attempts
isReconnectingRef.current = true; isReconnectingRef.current = true;
// Clear terminal immediately to prevent showing last line
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
// Increment attempt counter
reconnectAttempts.current++; 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(() => { reconnectTimeoutRef.current = setTimeout(() => {
// Check again if component is still mounted and should reconnect
if (isUnmountingRef.current || shouldNotReconnectRef.current) { if (isUnmountingRef.current || shouldNotReconnectRef.current) {
isReconnectingRef.current = false; isReconnectingRef.current = false;
return; return;
} }
// Check if we haven't exceeded max attempts during the timeout
if (reconnectAttempts.current > maxReconnectAttempts) { if (reconnectAttempts.current > maxReconnectAttempts) {
isReconnectingRef.current = false; isReconnectingRef.current = false;
return; return;
} }
if (terminal && hostConfig) { if (terminal && hostConfig) {
// Ensure terminal is clear before reconnecting
terminal.clear(); terminal.clear();
const cols = terminal.cols; const cols = terminal.cols;
const rows = terminal.rows; const rows = terminal.rows;
connectToHost(cols, rows); connectToHost(cols, rows);
} }
// Reset reconnecting flag after attempting connection
isReconnectingRef.current = false; isReconnectingRef.current = false;
}, 2000 * reconnectAttempts.current); // Exponential backoff }, 2000 * reconnectAttempts.current);
} }
function connectToHost(cols: number, rows: number) { function connectToHost(cols: number, rows: number) {
@@ -200,39 +189,30 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const wsUrl = isDev const wsUrl = isDev
? 'ws://localhost:8082' ? 'ws://localhost:8082'
: isElectron : isElectron
? (() => { ? (() => {
// Get configured server URL from window object (set by main-axios) const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081';
const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081'; const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://';
// Convert HTTP/HTTPS to WS/WSS and use nginx reverse proxy path const wsHost = baseUrl.replace(/^https?:\/\//, '');
const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://'; return `${wsProtocol}${wsHost}/ssh/websocket/`;
const wsHost = baseUrl.replace(/^https?:\/\//, ''); // Keep the port })()
return `${wsProtocol}${wsHost}/ssh/websocket/`; : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
})()
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
webSocketRef.current = ws; webSocketRef.current = ws;
wasDisconnectedBySSH.current = false; wasDisconnectedBySSH.current = false;
setConnectionError(null); setConnectionError(null);
shouldNotReconnectRef.current = false; // Reset reconnection flag shouldNotReconnectRef.current = false;
isReconnectingRef.current = false; // Reset reconnecting flag isReconnectingRef.current = false;
setIsConnecting(true); // Set connecting state setIsConnecting(true);
setupWebSocketListeners(ws, cols, rows); setupWebSocketListeners(ws, cols, rows);
} }
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) { function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
// 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(() => { connectionTimeoutRef.current = setTimeout(() => {
if (!isConnected) { if (!isConnected) {
// SSH connection didn't establish within timeout
// Clear terminal immediately when connection times out
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
@@ -240,18 +220,17 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (webSocketRef.current) { if (webSocketRef.current) {
webSocketRef.current.close(); webSocketRef.current.close();
} }
// Attempt reconnection if this was a reconnection attempt
if (reconnectAttempts.current > 0) { if (reconnectAttempts.current > 0) {
attemptReconnection(); attemptReconnection();
} }
} }
}, 10000); // 10 second timeout for SSH connection }, 10000);
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => { terminal.onData((data) => {
ws.send(JSON.stringify({type: 'input', data})); ws.send(JSON.stringify({type: 'input', data}));
}); });
pingIntervalRef.current = setInterval(() => { pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'})); ws.send(JSON.stringify({type: 'ping'}));
@@ -265,73 +244,59 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (msg.type === 'data') { if (msg.type === 'data') {
terminal.write(msg.data); terminal.write(msg.data);
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
// Handle different types of errors
const errorMessage = msg.message || t('terminal.unknownError'); 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('password') ||
errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('permission') ||
errorMessage.toLowerCase().includes('denied') || errorMessage.toLowerCase().includes('denied') ||
errorMessage.toLowerCase().includes('invalid') || errorMessage.toLowerCase().includes('invalid') ||
errorMessage.toLowerCase().includes('failed') || errorMessage.toLowerCase().includes('failed') ||
errorMessage.toLowerCase().includes('incorrect')) { errorMessage.toLowerCase().includes('incorrect')) {
toast.error(t('terminal.authError', { message: errorMessage })); toast.error(t('terminal.authError', {message: errorMessage}));
shouldNotReconnectRef.current = true; // Don't reconnect on auth errors shouldNotReconnectRef.current = true;
// Close terminal on auth errors
if (webSocketRef.current) { if (webSocketRef.current) {
webSocketRef.current.close(); webSocketRef.current.close();
} }
// Close the terminal tab immediately
if (onClose) { if (onClose) {
onClose(); onClose();
} }
return; return;
} }
// Check if it's a connection error that should trigger reconnection
if (errorMessage.toLowerCase().includes('connection') || if (errorMessage.toLowerCase().includes('connection') ||
errorMessage.toLowerCase().includes('timeout') || errorMessage.toLowerCase().includes('timeout') ||
errorMessage.toLowerCase().includes('network')) { errorMessage.toLowerCase().includes('network')) {
toast.error(t('terminal.connectionError', { message: errorMessage })); toast.error(t('terminal.connectionError', {message: errorMessage}));
setIsConnected(false); setIsConnected(false);
// Clear terminal immediately when connection error occurs
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
// Set connecting state immediately for reconnection
setIsConnecting(true); setIsConnecting(true);
attemptReconnection(); attemptReconnection();
return; 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') { } else if (msg.type === 'connected') {
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); // Clear connecting state setIsConnecting(false);
// Clear connection timeout since SSH connection is established
if (connectionTimeoutRef.current) { if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current); clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null; connectionTimeoutRef.current = null;
} }
// Show reconnected toast if this was a reconnection attempt
if (reconnectAttempts.current > 0) { if (reconnectAttempts.current > 0) {
toast.success(t('terminal.reconnected')); toast.success(t('terminal.reconnected'));
} }
// Reset reconnection counter and flags on successful connection
reconnectAttempts.current = 0; reconnectAttempts.current = 0;
isReconnectingRef.current = false; isReconnectingRef.current = false;
} else if (msg.type === 'disconnected') { } else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true; wasDisconnectedBySSH.current = true;
setIsConnected(false); setIsConnected(false);
// Clear terminal immediately when disconnected
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
// Set connecting state immediately for reconnection
setIsConnecting(true); setIsConnecting(true);
// Attempt reconnection for disconnections
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) { if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
attemptReconnection(); attemptReconnection();
} }
@@ -343,28 +308,22 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener('close', (event) => { ws.addEventListener('close', (event) => {
setIsConnected(false); setIsConnected(false);
// Clear terminal immediately when connection closes
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
// Set connecting state immediately for reconnection
setIsConnecting(true); setIsConnecting(true);
if (!wasDisconnectedBySSH.current && !isUnmountingRef.current && !shouldNotReconnectRef.current) { if (!wasDisconnectedBySSH.current && !isUnmountingRef.current && !shouldNotReconnectRef.current) {
// Attempt reconnection for unexpected disconnections
attemptReconnection(); attemptReconnection();
} }
}); });
ws.addEventListener('error', (event) => { ws.addEventListener('error', (event) => {
setIsConnected(false); setIsConnected(false);
setConnectionError(t('terminal.websocketError')); setConnectionError(t('terminal.websocketError'));
// Clear terminal immediately when WebSocket error occurs
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
// Set connecting state immediately for reconnection
setIsConnecting(true); setIsConnecting(true);
// Attempt reconnection for WebSocket errors
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) { if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
attemptReconnection(); attemptReconnection();
} }
@@ -486,23 +445,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const cols = terminal.cols; const cols = terminal.cols;
const rows = terminal.rows; 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); connectToHost(cols, rows);
}, 300); }, 300);
}); });
@@ -511,7 +453,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
isUnmountingRef.current = true; isUnmountingRef.current = true;
shouldNotReconnectRef.current = true; shouldNotReconnectRef.current = true;
isReconnectingRef.current = false; isReconnectingRef.current = false;
setIsConnecting(false); // Clear connecting state setIsConnecting(false);
resizeObserver.disconnect(); resizeObserver.disconnect();
element?.removeEventListener('contextmenu', handleContextMenu); element?.removeEventListener('contextmenu', handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
@@ -536,7 +478,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.focus(); terminal.focus();
} }
}, 0); }, 0);
if (terminal && !splitScreen) { if (terminal && !splitScreen) {
setTimeout(() => { setTimeout(() => {
terminal.focus(); terminal.focus();
@@ -560,8 +502,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
return ( return (
<div className="h-full w-full m-1 relative"> <div className="h-full w-full m-1 relative">
{/* Terminal */} {/* Terminal */}
<div <div
ref={xtermRef} ref={xtermRef}
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? 'opacity-100' : 'opacity-0'} overflow-hidden`} className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? 'opacity-100' : 'opacity-0'} overflow-hidden`}
onClick={() => { onClick={() => {
if (terminal && !splitScreen) { if (terminal && !splitScreen) {
@@ -569,12 +511,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} }
}} }}
/> />
{/* Connecting State */} {/* Connecting State */}
{isConnecting && ( {isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg"> <div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
<div className="flex items-center gap-3"> <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> <span className="text-gray-300">{t('terminal.connecting')}</span>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import React, {useState, useEffect, useCallback} from "react"; import React, {useState, useEffect, useCallback} from "react";
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx"; import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts"; import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
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 { export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
const [allHosts, setAllHosts] = useState<SSHHost[]>([]); const [allHosts, setAllHosts] = useState<SSHHost[]>([]);

View File

@@ -1,14 +1,12 @@
import React from "react"; import React from "react";
import {Button} from "@/components/ui/button.tsx"; 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 {Separator} from "@/components/ui/separator.tsx";
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import { import {
Loader2, Loader2,
Pin, Pin,
Terminal,
Network, Network,
FileEdit,
Tag, Tag,
Play, Play,
Square, Square,
@@ -16,11 +14,10 @@ import {
Clock, Clock,
Wifi, Wifi,
WifiOff, WifiOff,
Zap,
X X
} from "lucide-react"; } from "lucide-react";
import {Badge} from "@/components/ui/badge.tsx"; 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({ export function TunnelObject({
host, host,
@@ -227,9 +224,12 @@ export function TunnelObject({
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} {statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
</div> </div>
<div> <div>
{t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })} {t('tunnels.attempt', {
current: status.retryCount,
max: status.maxRetries
})}
{status.nextRetryIn && ( {status.nextRetryIn && (
<span> {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })}</span> <span> {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
)} )}
</div> </div>
</div> </div>
@@ -408,9 +408,12 @@ export function TunnelObject({
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} {statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
</div> </div>
<div> <div>
{t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })} {t('tunnels.attempt', {
current: status.retryCount,
max: status.maxRetries
})}
{status.nextRetryIn && ( {status.nextRetryIn && (
<span> {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })}</span> <span> {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import {TunnelObject} from "./TunnelObject.tsx"; import {TunnelObject} from "./TunnelObject.tsx";
import {useTranslation} from 'react-i18next'; 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 { interface SSHTunnelViewerProps {
hosts: SSHHost[]; hosts: SSHHost[];

View File

@@ -5,10 +5,10 @@ import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx"
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx" import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx" import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx"; import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx"; import {AdminSettings} from "@/ui/Desktop/Admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx"; import {UserProfile} from "@/ui/Desktop/User/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx"; import {Toaster} from "@/components/ui/sonner.tsx";
import { getUserInfo, getCookie, setCookie } from "@/ui/main-axios.ts"; import {getUserInfo, getCookie} from "@/ui/main-axios.ts";
function AppContent() { function AppContent() {
const [view, setView] = useState<string>("homepage") const [view, setView] = useState<string>("homepage")
@@ -92,13 +92,13 @@ function AppContent() {
transparent 100% transparent 100%
)`, )`,
backgroundSize: '80px 80px' backgroundSize: '80px 80px'
}} /> }}/>
</div> </div>
)} )}
{!isAuthenticated && !authLoading && ( {!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]"> <div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage <Homepage
onSelectView={handleSelectView} onSelectView={handleSelectView}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
authLoading={authLoading} authLoading={authLoading}
@@ -117,13 +117,13 @@ function AppContent() {
> >
{showTerminalView && ( {showTerminalView && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AppView isTopbarOpen={isTopbarOpen} /> <AppView isTopbarOpen={isTopbarOpen}/>
</div> </div>
)} )}
{showHome && ( {showHome && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<Homepage <Homepage
onSelectView={handleSelectView} onSelectView={handleSelectView}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
authLoading={authLoading} authLoading={authLoading}
@@ -135,26 +135,26 @@ function AppContent() {
{showSshManager && ( {showSshManager && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} /> <HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/>
</div> </div>
)} )}
{showAdmin && ( {showAdmin && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AdminSettings isTopbarOpen={isTopbarOpen} /> <AdminSettings isTopbarOpen={isTopbarOpen}/>
</div> </div>
)} )}
{showProfile && ( {showProfile && (
<div className="h-screen w-full visible pointer-events-auto static overflow-auto"> <div className="h-screen w-full visible pointer-events-auto static overflow-auto">
<UserProfile isTopbarOpen={isTopbarOpen} /> <UserProfile isTopbarOpen={isTopbarOpen}/>
</div> </div>
)} )}
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/> <TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar> </LeftSidebar>
)} )}
<Toaster <Toaster
position="bottom-right" position="bottom-right"
richColors={false} richColors={false}
closeButton closeButton
@@ -168,7 +168,7 @@ function AppContent() {
function DesktopApp() { function DesktopApp() {
return ( return (
<TabProvider> <TabProvider>
<AppContent /> <AppContent/>
</TabProvider> </TabProvider>
); );
} }

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,9 +1,8 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx"; import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.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 {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"; import {useTranslation} from "react-i18next";
interface HomepageProps { interface HomepageProps {
@@ -15,22 +14,19 @@ interface HomepageProps {
} }
export function Homepage({ export function Homepage({
onSelectView,
isAuthenticated, isAuthenticated,
authLoading, authLoading,
onAuthSuccess, onAuthSuccess,
isTopbarOpen isTopbarOpen
}: HomepageProps): React.ReactElement { }: HomepageProps): React.ReactElement {
const {t} = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = 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 topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = 26; // Assuming sidebar is collapsed for homepage const leftMarginPx = 26;
const bottomMarginPx = 8; const bottomMarginPx = 8;
useEffect(() => { useEffect(() => {
@@ -83,7 +79,7 @@ export function Homepage({
/> />
</div> </div>
) : ( ) : (
<div <div
className="w-full h-full flex items-center justify-center" className="w-full h-full flex items-center justify-center"
style={{ style={{
marginLeft: leftMarginPx, marginLeft: leftMarginPx,

View File

@@ -4,7 +4,7 @@ import {Button} from "@/components/ui/button.tsx";
import {Badge} from "@/components/ui/badge.tsx"; import {Badge} from "@/components/ui/badge.tsx";
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import type { TermixAlert } from '../../../types/index.js'; import type {TermixAlert} from '../../../types/index.js';
interface AlertCardProps { interface AlertCardProps {
alert: TermixAlert; alert: TermixAlert;
@@ -56,7 +56,7 @@ const getTypeBadgeVariant = (type?: string) => {
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
const {t} = useTranslation(); const {t} = useTranslation();
if (!alert) { if (!alert) {
return null; return null;
} }

View File

@@ -1,9 +1,9 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
import {Button} from "@/components/ui/button.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 {useTranslation} from "react-i18next";
import type { TermixAlert } from '../../../types/index.js'; import type {TermixAlert} from '../../../types/index.js';
interface AlertManagerProps { interface AlertManagerProps {
userId: string | null; userId: string | null;
@@ -49,7 +49,6 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
setAlerts(sortedAlerts); setAlerts(sortedAlerts);
setCurrentAlertIndex(0); setCurrentAlertIndex(0);
} catch (err) { } catch (err) {
console.error('Failed to fetch user alerts:', err);
const {toast} = await import('sonner'); const {toast} = await import('sonner');
toast.error(t('homepage.failedToLoadAlerts')); toast.error(t('homepage.failedToLoadAlerts'));
setError(t('homepage.failedToLoadAlerts')); setError(t('homepage.failedToLoadAlerts'));

View File

@@ -1,5 +1,4 @@
import React, {useState, useEffect} from "react"; import React, {useState, useEffect} from "react";
import {Eye, EyeOff} from "lucide-react";
import {cn} from "@/lib/utils.ts"; import {cn} from "@/lib/utils.ts";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {Input} from "@/components/ui/input.tsx"; import {Input} from "@/components/ui/input.tsx";
@@ -24,9 +23,8 @@ import {
getCookie, getCookie,
getServerConfig, getServerConfig,
isElectron, isElectron,
type ServerConfig
} from "../../main-axios.ts"; } 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"> { interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void; setLoggedIn: (loggedIn: boolean) => void;
@@ -61,14 +59,14 @@ export function HomepageAuth({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false);
const [visibility, setVisibility] = useState({ const [visibility, setVisibility] = useState({
password: false, password: false,
signupConfirm: false, signupConfirm: false,
resetNew: false, resetNew: false,
resetConfirm: false resetConfirm: false
}); });
const toggleVisibility = (field: keyof typeof visibility) => { 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 [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false); const [internalLoggedIn, setInternalLoggedIn] = useState(false);
@@ -83,7 +81,7 @@ export function HomepageAuth({
const [tempToken, setTempToken] = useState(""); const [tempToken, setTempToken] = useState("");
const [resetLoading, setResetLoading] = useState(false); const [resetLoading, setResetLoading] = useState(false);
const [resetSuccess, setResetSuccess] = useState(false); const [resetSuccess, setResetSuccess] = useState(false);
const [totpRequired, setTotpRequired] = useState(false); const [totpRequired, setTotpRequired] = useState(false);
const [totpCode, setTotpCode] = useState(""); const [totpCode, setTotpCode] = useState("");
const [totpTempToken, setTotpTempToken] = useState(""); const [totpTempToken, setTotpTempToken] = useState("");
@@ -159,23 +157,23 @@ export function HomepageAuth({
await registerUser(localUsername, password); await registerUser(localUsername, password);
res = await loginUser(localUsername, password); res = await loginUser(localUsername, password);
} }
if (res.requires_totp) { if (res.requires_totp) {
setTotpRequired(true); setTotpRequired(true);
setTotpTempToken(res.temp_token); setTotpTempToken(res.temp_token);
setLoading(false); setLoading(false);
return; return;
} }
if (!res || !res.token) { if (!res || !res.token) {
throw new Error(t('errors.noTokenReceived')); throw new Error(t('errors.noTokenReceived'));
} }
setCookie("jwt", res.token); setCookie("jwt", res.token);
[meRes] = await Promise.all([ [meRes] = await Promise.all([
getUserInfo(), getUserInfo(),
]); ]);
setInternalLoggedIn(true); setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
@@ -300,17 +298,17 @@ export function HomepageAuth({
setError(null); setError(null);
setTotpLoading(true); setTotpLoading(true);
try { try {
const res = await verifyTOTPLogin(totpTempToken, totpCode); const res = await verifyTOTPLogin(totpTempToken, totpCode);
if (!res || !res.token) { if (!res || !res.token) {
throw new Error(t('errors.noTokenReceived')); throw new Error(t('errors.noTokenReceived'));
} }
setCookie("jwt", res.token); setCookie("jwt", res.token);
const meRes = await getUserInfo(); const meRes = await getUserInfo();
setInternalLoggedIn(true); setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
@@ -408,58 +406,51 @@ export function HomepageAuth({
</svg> </svg>
); );
// Check if we need to show server config for Electron
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(null); const [showServerConfig, setShowServerConfig] = useState<boolean | null>(null);
const [currentServerUrl, setCurrentServerUrl] = useState<string>(''); const [currentServerUrl, setCurrentServerUrl] = useState<string>('');
useEffect(() => { useEffect(() => {
const checkServerConfig = async () => { const checkServerConfig = async () => {
if (isElectron()) { if (isElectron()) {
try { try {
const config = await getServerConfig(); const config = await getServerConfig();
console.log('Desktop HomepageAuth - Server config check:', config);
setCurrentServerUrl(config?.serverUrl || ''); setCurrentServerUrl(config?.serverUrl || '');
setShowServerConfig(!config || !config.serverUrl); setShowServerConfig(!config || !config.serverUrl);
} catch (error) { } catch (error) {
console.log('Desktop HomepageAuth - No server config found, showing config screen');
setShowServerConfig(true); setShowServerConfig(true);
} }
} else { } else {
setShowServerConfig(false); setShowServerConfig(false);
} }
}; };
checkServerConfig(); checkServerConfig();
}, []); }, []);
if (showServerConfig === null) { if (showServerConfig === null) {
// Still checking
return ( return (
<div <div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`} className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`}
{...props} {...props}
> >
<div className="flex items-center justify-center h-32"> <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>
</div> </div>
); );
} }
if (showServerConfig) { if (showServerConfig) {
console.log('Desktop HomepageAuth - SHOWING SERVER CONFIG SCREEN');
return ( return (
<div <div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`} className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`}
{...props} {...props}
> >
<ServerConfigComponent <ServerConfigComponent
onServerConfigured={() => { onServerConfigured={() => {
console.log('Server configured, reloading page');
window.location.reload(); window.location.reload();
}} }}
onCancel={() => { onCancel={() => {
console.log('Cancelled server config, going back to login');
setShowServerConfig(false); setShowServerConfig(false);
}} }}
isFirstTime={!currentServerUrl} isFirstTime={!currentServerUrl}
@@ -509,7 +500,7 @@ export function HomepageAuth({
<h2 className="text-xl font-bold mb-1">{t('auth.twoFactorAuth')}</h2> <h2 className="text-xl font-bold mb-1">{t('auth.twoFactorAuth')}</h2>
<p className="text-muted-foreground">{t('auth.enterCode')}</p> <p className="text-muted-foreground">{t('auth.enterCode')}</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="totp-code">{t('auth.verifyCode')}</Label> <Label htmlFor="totp-code">{t('auth.verifyCode')}</Label>
<Input <Input
@@ -527,7 +518,7 @@ export function HomepageAuth({
{t('auth.backupCode')} {t('auth.backupCode')}
</p> </p>
</div> </div>
<Button <Button
type="button" type="button"
className="w-full h-11 text-base font-semibold" className="w-full h-11 text-base font-semibold"
@@ -536,7 +527,7 @@ export function HomepageAuth({
> >
{totpLoading ? Spinner : t('auth.verifyCode')} {totpLoading ? Spinner : t('auth.verifyCode')}
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -553,7 +544,7 @@ export function HomepageAuth({
</Button> </Button>
</div> </div>
)} )}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && ( {(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
<> <>
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">
@@ -758,29 +749,30 @@ export function HomepageAuth({
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="new-password">{t('auth.newPassword')}</Label> <Label htmlFor="new-password">{t('auth.newPassword')}</Label>
<PasswordInput <PasswordInput
id="new-password" id="new-password"
required required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200" className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading} disabled={resetLoading}
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label> <Label
<PasswordInput htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label>
id="confirm-password" <PasswordInput
required id="confirm-password"
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200" required
value={confirmPassword} className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
onChange={e => setConfirmPassword(e.target.value)} value={confirmPassword}
disabled={resetLoading} onChange={e => setConfirmPassword(e.target.value)}
autoComplete="new-password" disabled={resetLoading}
/> autoComplete="new-password"
</div> />
</div>
<Button <Button
type="button" type="button"
className="w-full h-11 text-base font-semibold" className="w-full h-11 text-base font-semibold"
@@ -823,26 +815,26 @@ export function HomepageAuth({
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="password">{t('common.password')}</Label> <Label htmlFor="password">{t('common.password')}</Label>
<PasswordInput <PasswordInput
id="password" id="password"
required required
className="h-11 text-base" className="h-11 text-base"
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/> disabled={loading || internalLoggedIn}/>
</div> </div>
{tab === "signup" && ( {tab === "signup" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label> <Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
<PasswordInput <PasswordInput
id="signup-confirm-password" id="signup-confirm-password"
required required
className="h-11 text-base" className="h-11 text-base"
value={signupConfirmPassword} value={signupConfirmPassword}
onChange={e => setSignupConfirmPassword(e.target.value)} onChange={e => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}/> disabled={loading || internalLoggedIn}/>
</div> </div>
)} )}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold" <Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}> disabled={loading || internalLoggedIn}>
@@ -863,13 +855,13 @@ export function HomepageAuth({
)} )}
</form> </form>
)} )}
<div className="mt-6 pt-4 border-t border-dark-border space-y-4"> <div className="mt-6 pt-4 border-t border-dark-border space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label className="text-sm text-muted-foreground">{t('common.language')}</Label> <Label className="text-sm text-muted-foreground">{t('common.language')}</Label>
</div> </div>
<LanguageSwitcher /> <LanguageSwitcher/>
</div> </div>
{isElectron() && currentServerUrl && ( {isElectron() && currentServerUrl && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -1,8 +1,7 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Separator} from "@/components/ui/separator.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"; import {useTranslation} from "react-i18next";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> { interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
@@ -90,7 +89,8 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
}; };
return ( 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> <div>
<h3 className="text-lg font-bold mb-3 text-white">{t('common.updatesAndReleases')}</h3> <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"> <Alert className="bg-dark-bg-darker border-dark-border text-white">
<AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle> <AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle>
<AlertDescription className="text-gray-300"> <AlertDescription className="text-gray-300">
{t('common.newVersionAvailable', { version: versionInfo.version })} {t('common.newVersionAvailable', {version: versionInfo.version})}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}

View File

@@ -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[]; 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; 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; const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
if (layoutTabs.length === 2) { if (layoutTabs.length === 2) {
@@ -226,7 +230,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(a.id)] = el; panelRefs.current[String(a.id)] = el;
}} className="h-full w-full flex flex-col bg-transparent relative"> }} 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> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
@@ -235,7 +240,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(b.id)] = el; panelRefs.current[String(b.id)] = el;
}} className="h-full w-full flex flex-col bg-transparent relative"> }} 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} {b.title}
<ResetButton onClick={handleReset}/> <ResetButton onClick={handleReset}/>
</div> </div>
@@ -260,7 +266,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(a.id)] = el; panelRefs.current[String(a.id)] = el;
}} className="h-full w-full flex flex-col relative"> }} 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> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
@@ -269,7 +276,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(b.id)] = el; panelRefs.current[String(b.id)] = el;
}} className="h-full w-full flex flex-col relative"> }} 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} {b.title}
<ResetButton onClick={handleReset}/> <ResetButton onClick={handleReset}/>
</div> </div>
@@ -283,7 +291,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(c.id)] = el; panelRefs.current[String(c.id)] = el;
}} className="h-full w-full flex flex-col relative"> }} 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> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePrimitive.PanelGroup> </ResizablePrimitive.PanelGroup>
@@ -305,7 +314,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(a.id)] = el; panelRefs.current[String(a.id)] = el;
}} className="h-full w-full flex flex-col relative"> }} 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> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
@@ -314,7 +324,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(b.id)] = el; panelRefs.current[String(b.id)] = el;
}} className="h-full w-full flex flex-col relative"> }} 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} {b.title}
<ResetButton onClick={handleReset}/> <ResetButton onClick={handleReset}/>
</div> </div>
@@ -332,7 +343,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(c.id)] = el; panelRefs.current[String(c.id)] = el;
}} className="h-full w-full flex flex-col relative"> }} 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> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
@@ -341,7 +353,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div ref={el => { <div ref={el => {
panelRefs.current[String(d.id)] = el; panelRefs.current[String(d.id)] = el;
}} className="h-full w-full flex flex-col relative"> }} 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> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
@@ -356,7 +369,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
const currentTabData = tabs.find((tab: any) => tab.id === currentTab); const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
const isFileManager = currentTabData?.type === 'file_manager'; const isFileManager = currentTabData?.type === 'file_manager';
const isSplitScreen = allSplitScreenTab.length > 0; const isSplitScreen = allSplitScreenTab.length > 0;
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;

View File

@@ -35,7 +35,7 @@ interface FolderCardProps {
isLast: boolean; 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 [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => { const toggleExpanded = () => {

View File

@@ -5,7 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react"; import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {getServerStatusById} from "@/ui/main-axios.ts"; import {getServerStatusById} from "@/ui/main-axios.ts";
import type { HostProps } from '../../../../types/index.js'; import type {HostProps} from '../../../../types/index.js';
export function Host({host}: HostProps): React.ReactElement { export function Host({host}: HostProps): React.ReactElement {
const {addTab} = useTabs(); const {addTab} = useTabs();

View File

@@ -2,7 +2,7 @@ import React, {useState} from 'react';
import { import {
ChevronUp, User2, HardDrive, Menu, ChevronRight ChevronUp, User2, HardDrive, Menu, ChevronRight
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {getCookie, setCookie, isElectron} from "@/ui/main-axios.ts"; import {getCookie, setCookie, isElectron} from "@/ui/main-axios.ts";
import { import {
@@ -27,7 +27,7 @@ import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
import {FolderCard} from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx"; import {FolderCard} from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts"; import {getSSHHosts} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { deleteAccount } from "@/ui/main-axios.ts"; import {deleteAccount} from "@/ui/main-axios.ts";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -68,12 +68,11 @@ function handleLogout() {
} else { } else {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
} }
window.location.reload(); window.location.reload();
} }
export function LeftSidebar({ export function LeftSidebar({
onSelectView, onSelectView,
getView, getView,
@@ -82,8 +81,8 @@ export function LeftSidebar({
username, username,
children, children,
}: SidebarProps): React.ReactElement { }: SidebarProps): React.ReactElement {
const { t } = useTranslation(); const {t} = useTranslation();
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState(""); const [deletePassword, setDeletePassword] = React.useState("");
const [deleteLoading, setDeleteLoading] = React.useState(false); const [deleteLoading, setDeleteLoading] = React.useState(false);
@@ -128,7 +127,6 @@ export function LeftSidebar({
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const fetchHosts = React.useCallback(async () => { const fetchHosts = React.useCallback(async () => {
try { try {
const newHosts = await getSSHHosts(); const newHosts = await getSSHHosts();
@@ -179,8 +177,7 @@ export function LeftSidebar({
setTimeout(() => { setTimeout(() => {
setHosts(newHosts); setHosts(newHosts);
prevHostsRef.current = newHosts; prevHostsRef.current = newHosts;
// Update hostConfig in existing tabs
newHosts.forEach(newHost => { newHosts.forEach(newHost => {
updateHostConfig(newHost.id, newHost); updateHostConfig(newHost.id, newHost);
}); });
@@ -193,7 +190,7 @@ export function LeftSidebar({
React.useEffect(() => { React.useEffect(() => {
fetchHosts(); fetchHosts();
const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds const interval = setInterval(fetchHosts, 300000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchHosts]); }, [fetchHosts]);
@@ -302,7 +299,8 @@ export function LeftSidebar({
<Separator className="p-0.25"/> <Separator className="p-0.25"/>
<SidebarContent> <SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2"> <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} onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}> title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}>
<HardDrive strokeWidth="2.5"/> <HardDrive strokeWidth="2.5"/>

View File

@@ -67,7 +67,7 @@ export function Tab({
> >
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ? {isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
<FolderIcon className="mr-1 h-4 w-4"/> : isUserProfile ? <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'))} {title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : isUserProfile ? t('nav.userProfile') : t('nav.terminal'))}
</Button> </Button>
{canSplit && ( {canSplit && (

View File

@@ -1,6 +1,6 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import type { TabContextTab } from '../../../types/index.js'; import type {TabContextTab} from '../../../types/index.js';
export type Tab = TabContextTab; export type Tab = TabContextTab;

View File

@@ -5,7 +5,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"; } from "@/components/ui/dropdown-menu.tsx";
import { Button } from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import { import {
ChevronDown, ChevronDown,
Home, Home,
@@ -16,31 +16,31 @@ import {
Network as SshManagerIcon, Network as SshManagerIcon,
User as UserIcon User as UserIcon
} from "lucide-react"; } from "lucide-react";
import { useTabs, type Tab } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import {useTabs, type Tab} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
export function TabDropdown(): React.ReactElement { export function TabDropdown(): React.ReactElement {
const { tabs, currentTab, setCurrentTab } = useTabs(); const {tabs, currentTab, setCurrentTab} = useTabs();
const { t } = useTranslation(); const {t} = useTranslation();
const getTabIcon = (tabType: Tab['type']) => { const getTabIcon = (tabType: Tab['type']) => {
switch (tabType) { switch (tabType) {
case 'home': case 'home':
return <Home className="h-4 w-4" />; return <Home className="h-4 w-4"/>;
case 'terminal': case 'terminal':
return <TerminalIcon className="h-4 w-4" />; return <TerminalIcon className="h-4 w-4"/>;
case 'server': case 'server':
return <ServerIcon className="h-4 w-4" />; return <ServerIcon className="h-4 w-4"/>;
case 'file_manager': case 'file_manager':
return <FolderIcon className="h-4 w-4" />; return <FolderIcon className="h-4 w-4"/>;
case 'user_profile': case 'user_profile':
return <UserIcon className="h-4 w-4" />; return <UserIcon className="h-4 w-4"/>;
case 'ssh_manager': case 'ssh_manager':
return <SshManagerIcon className="h-4 w-4" />; return <SshManagerIcon className="h-4 w-4"/>;
case 'admin': case 'admin':
return <AdminIcon className="h-4 w-4" />; return <AdminIcon className="h-4 w-4"/>;
default: 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); setCurrentTab(tabId);
}; };
// If only one tab (home), don't show dropdown
if (tabs.length <= 1) { if (tabs.length <= 1) {
return null; return null;
} }
@@ -79,9 +78,9 @@ export function TabDropdown(): React.ReactElement {
<Button <Button
variant="outline" variant="outline"
className="w-[30px] h-[30px] border-dark-border" 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> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -105,7 +104,7 @@ export function TabDropdown(): React.ReactElement {
{getTabDisplayTitle(tab)} {getTabDisplayTitle(tab)}
</span> </span>
{isActive && ( {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> </DropdownMenuItem>
); );

View File

@@ -255,8 +255,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</div> </div>
<div className="flex items-center justify-center gap-2 flex-1 px-2"> <div className="flex items-center justify-center gap-2 flex-1 px-2">
<TabDropdown /> <TabDropdown/>
<Button <Button
variant="outline" variant="outline"
className="w-[30px] h-[30px]" className="w-[30px] h-[30px]"
@@ -348,7 +348,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
{isRecording && ( {isRecording && (
<> <>
<div className="space-y-2"> <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"> <div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map(tab => ( {terminalTabs.map(tab => (
<Button <Button
@@ -370,7 +371,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</div> </div>
<div className="space-y-2"> <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 <Input
id="ssh-tools-input" id="ssh-tools-input"
placeholder={t('placeholders.typeHere')} placeholder={t('placeholders.typeHere')}
@@ -381,7 +383,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
readOnly readOnly
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })} {t('sshTools.commandsWillBeSent', {count: selectedTabIds.length})}
</p> </p>
</div> </div>
</> </>

View File

@@ -1,44 +1,42 @@
// Language switcher component for changing UI language
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import {useTranslation} from 'react-i18next';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select.tsx'; } from '@/components/ui/select.tsx';
import { Globe } from 'lucide-react'; import {Globe} from 'lucide-react';
const languages = [ const languages = [
{ code: 'en', name: 'English', nativeName: 'English' }, {code: 'en', name: 'English', nativeName: 'English'},
{ code: 'zh', name: 'Chinese', nativeName: '中文' }, {code: 'zh', name: 'Chinese', nativeName: '中文'},
]; ];
export function LanguageSwitcher() { export function LanguageSwitcher() {
const { i18n, t } = useTranslation(); const {i18n, t} = useTranslation();
const handleLanguageChange = (value: string) => { const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value); i18n.changeLanguage(value);
// Save to localStorage for persistence localStorage.setItem('i18nextLng', value);
localStorage.setItem('i18nextLng', value); };
};
return ( return (
<div className="flex items-center gap-2 relative z-[99999]"> <div className="flex items-center gap-2 relative z-[99999]">
<Globe className="h-4 w-4 text-muted-foreground" /> <Globe className="h-4 w-4 text-muted-foreground"/>
<Select value={i18n.language} onValueChange={handleLanguageChange}> <Select value={i18n.language} onValueChange={handleLanguageChange}>
<SelectTrigger className="w-[120px]"> <SelectTrigger className="w-[120px]">
<SelectValue placeholder={t('placeholders.language')} /> <SelectValue placeholder={t('placeholders.language')}/>
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[99999]"> <SelectContent className="z-[99999]">
{languages.map((lang) => ( {languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}> <SelectItem key={lang.code} value={lang.code}>
{lang.nativeName} {lang.nativeName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
); );
} }

View File

@@ -1,22 +1,22 @@
import React, { useState } from "react"; import React, {useState} from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"; import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx"; import {Input} from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx"; import {PasswordInput} from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx"; import {Label} from "@/components/ui/label.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react"; import {Shield, Copy, Download, AlertCircle, CheckCircle2} from "lucide-react";
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts"; import {setupTOTP, enableTOTP, disableTOTP, generateBackupCodes} from "@/ui/main-axios.ts";
import { toast } from "sonner"; import {toast} from "sonner";
import { useTranslation } from 'react-i18next'; import {useTranslation} from 'react-i18next';
interface TOTPSetupProps { interface TOTPSetupProps {
isEnabled: boolean; isEnabled: boolean;
onStatusChange?: (enabled: boolean) => void; onStatusChange?: (enabled: boolean) => void;
} }
export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) { export function TOTPSetup({isEnabled: initialEnabled, onStatusChange}: TOTPSetupProps) {
const {t} = useTranslation(); const {t} = useTranslation();
const [isEnabled, setIsEnabled] = useState(initialEnabled); const [isEnabled, setIsEnabled] = useState(initialEnabled);
const [isSettingUp, setIsSettingUp] = useState(false); const [isSettingUp, setIsSettingUp] = useState(false);
@@ -109,8 +109,8 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
`Generated: ${new Date().toISOString()}\n\n` + `Generated: ${new Date().toISOString()}\n\n` +
`Keep these codes in a safe place. Each code can only be used once.\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'); 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 url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -133,7 +133,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" /> <Shield className="w-5 h-5"/>
{t('auth.twoFactorTitle')} {t('auth.twoFactorTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -142,7 +142,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Alert> <Alert>
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4"/>
<AlertTitle>{t('common.enabled')}</AlertTitle> <AlertTitle>{t('common.enabled')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t('auth.twoFactorActive')} {t('auth.twoFactorActive')}
@@ -154,16 +154,16 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
<TabsTrigger value="disable">{t('auth.disable2FA')}</TabsTrigger> <TabsTrigger value="disable">{t('auth.disable2FA')}</TabsTrigger>
<TabsTrigger value="backup">{t('auth.backupCodes')}</TabsTrigger> <TabsTrigger value="backup">{t('auth.backupCodes')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="disable" className="space-y-4"> <TabsContent value="disable" className="space-y-4">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4"/>
<AlertTitle>{t('common.warning')}</AlertTitle> <AlertTitle>{t('common.warning')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t('auth.disableTwoFactorWarning')} {t('auth.disableTwoFactorWarning')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="disable-password">{t('auth.passwordOrTotpCode')}</Label> <Label htmlFor="disable-password">{t('auth.passwordOrTotpCode')}</Label>
<PasswordInput <PasswordInput
@@ -182,7 +182,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
/> />
</div> </div>
<Button <Button
variant="destructive" variant="destructive"
onClick={handleDisable} onClick={handleDisable}
@@ -191,12 +191,12 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
{t('auth.disableTwoFactor')} {t('auth.disableTwoFactor')}
</Button> </Button>
</TabsContent> </TabsContent>
<TabsContent value="backup" className="space-y-4"> <TabsContent value="backup" className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('auth.generateNewBackupCodesText')} {t('auth.generateNewBackupCodesText')}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="backup-password">{t('auth.passwordOrTotpCode')}</Label> <Label htmlFor="backup-password">{t('auth.passwordOrTotpCode')}</Label>
<PasswordInput <PasswordInput
@@ -215,14 +215,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
/> />
</div> </div>
<Button <Button
onClick={handleGenerateNewBackupCodes} onClick={handleGenerateNewBackupCodes}
disabled={loading || (!password && !disableCode)} disabled={loading || (!password && !disableCode)}
> >
{t('auth.generateNewBackupCodes')} {t('auth.generateNewBackupCodes')}
</Button> </Button>
{backupCodes.length > 0 && ( {backupCodes.length > 0 && (
<div className="space-y-2 mt-4"> <div className="space-y-2 mt-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@@ -232,7 +232,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
variant="outline" variant="outline"
onClick={downloadBackupCodes} onClick={downloadBackupCodes}
> >
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2"/>
{t('auth.download')} {t('auth.download')}
</Button> </Button>
</div> </div>
@@ -248,7 +248,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4"/>
<AlertTitle>{t('common.error')}</AlertTitle> <AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
@@ -269,9 +269,9 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex justify-center"> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('auth.manualEntryCode')}</Label> <Label>{t('auth.manualEntryCode')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -285,14 +285,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
variant="outline" variant="outline"
onClick={() => copyToClipboard(secret, "Secret key")} onClick={() => copyToClipboard(secret, "Secret key")}
> >
<Copy className="w-4 h-4" /> <Copy className="w-4 h-4"/>
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('auth.cannotScanQRText')} {t('auth.cannotScanQRText')}
</p> </p>
</div> </div>
<Button onClick={() => setSetupStep("verify")} className="w-full"> <Button onClick={() => setSetupStep("verify")} className="w-full">
{t('auth.nextVerifyCode')} {t('auth.nextVerifyCode')}
</Button> </Button>
@@ -323,15 +323,15 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
className="text-center text-2xl tracking-widest font-mono" className="text-center text-2xl tracking-widest font-mono"
/> />
</div> </div>
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4"/>
<AlertTitle>{t('common.error')}</AlertTitle> <AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
@@ -364,13 +364,13 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4"/>
<AlertTitle>{t('common.important')}</AlertTitle> <AlertTitle>{t('common.important')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t('auth.importantBackupCodesText')} {t('auth.importantBackupCodesText')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Label>Your Backup Codes</Label> <Label>Your Backup Codes</Label>
@@ -379,7 +379,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
variant="outline" variant="outline"
onClick={downloadBackupCodes} onClick={downloadBackupCodes}
> >
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2"/>
Download Download
</Button> </Button>
</div> </div>
@@ -392,7 +392,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
))} ))}
</div> </div>
</div> </div>
<Button onClick={handleComplete} className="w-full"> <Button onClick={handleComplete} className="w-full">
{t('auth.completeSetup')} {t('auth.completeSetup')}
</Button> </Button>
@@ -405,14 +405,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" /> <Shield className="w-5 h-5"/>
{t('auth.twoFactorTitle')} {t('auth.twoFactorTitle')}
</CardTitle> </CardTitle>
<CardDescription className="space-y-2"> <CardDescription className="space-y-2">
<p>{t('auth.addExtraSecurityLayer')}.</p> <p>{t('auth.addExtraSecurityLayer')}.</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 px-3 text-xs" className="h-8 px-3 text-xs"
onClick={() => window.open('https://docs.termix.site/totp', '_blank')} onClick={() => window.open('https://docs.termix.site/totp', '_blank')}
> >
@@ -422,20 +422,20 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4"/>
<AlertTitle>{t('common.notEnabled')}</AlertTitle> <AlertTitle>{t('common.notEnabled')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t('auth.notEnabledText')} {t('auth.notEnabledText')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button onClick={handleSetupStart} disabled={loading} className="w-full"> <Button onClick={handleSetupStart} disabled={loading} className="w-full">
{loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')} {loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')}
</Button> </Button>
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4"/>
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>

View File

@@ -1,7 +1,4 @@
import React, {useState, useEffect} from "react"; 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 {Label} from "@/components/ui/label.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
@@ -10,13 +7,11 @@ import {User, Shield, Key, AlertCircle} from "lucide-react";
import {TOTPSetup} from "@/ui/Desktop/User/TOTPSetup.tsx"; import {TOTPSetup} from "@/ui/Desktop/User/TOTPSetup.tsx";
import {getUserInfo} from "@/ui/main-axios.ts"; import {getUserInfo} from "@/ui/main-axios.ts";
import {getVersionInfo} from "@/ui/main-axios.ts"; import {getVersionInfo} from "@/ui/main-axios.ts";
import {toast} from "sonner";
import {PasswordReset} from "@/ui/Desktop/User/PasswordReset.tsx"; import {PasswordReset} from "@/ui/Desktop/User/PasswordReset.tsx";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {LanguageSwitcher} from "@/ui/Desktop/User/LanguageSwitcher.tsx"; import {LanguageSwitcher} from "@/ui/Desktop/User/LanguageSwitcher.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx";
interface UserProfileProps { interface UserProfileProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
} }
@@ -45,7 +40,6 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
const info = await getVersionInfo(); const info = await getVersionInfo();
setVersionInfo({version: info.localVersion}); setVersionInfo({version: info.localVersion});
} catch (err) { } catch (err) {
console.error("Failed to load version info", err);
const {toast} = await import('sonner'); const {toast} = await import('sonner');
toast.error(t('user.failedToLoadVersionInfo')); toast.error(t('user.failedToLoadVersionInfo'));
} }
@@ -88,7 +82,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
if (loading) { if (loading) {
return ( 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="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t('nav.userProfile')}</h1> <h1 className="font-bold text-lg">{t('nav.userProfile')}</h1>
@@ -104,7 +99,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
if (error || !userInfo) { if (error || !userInfo) {
return ( 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="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t('nav.userProfile')}</h1> <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"> <Alert variant="destructive" className="bg-red-900/20 border-red-500/50">
<AlertCircle className="h-4 w-4"/> <AlertCircle className="h-4 w-4"/>
<AlertTitle className="text-red-400">{t('common.error')}</AlertTitle> <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> </Alert>
</div> </div>
</div> </div>
@@ -123,7 +120,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
} }
return ( 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="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t('nav.userProfile')}</h1> <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"> <div className="px-6 py-4 overflow-auto flex-1">
<Tabs defaultValue="profile" className="w-full"> <Tabs defaultValue="profile" className="w-full">
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border"> <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"/> <User className="w-4 h-4"/>
{t('nav.userProfile')} {t('nav.userProfile')}
</TabsTrigger> </TabsTrigger>
{!userInfo.is_oidc && ( {!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"/> <Shield className="w-4 h-4"/>
{t('profile.security')} {t('profile.security')}
</TabsTrigger> </TabsTrigger>

View File

@@ -5,7 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react"; import {Server, Terminal} from "lucide-react";
import {getServerStatusById} from "@/ui/main-axios.ts"; import {getServerStatusById} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Mobile/Apps/Navigation/Tabs/TabContext.tsx"; 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 { export function Host({host, onHostConnect}: HostProps): React.ReactElement {
const {addTab} = useTabs(); const {addTab} = useTabs();

View File

@@ -1,6 +1,6 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import type { TabContextTab } from '../../../../types/index.js'; import type {TabContextTab} from '../../../../types/index.js';
export type Tab = TabContextTab; export type Tab = TabContextTab;

View File

@@ -185,7 +185,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
textarea.blur(); textarea.blur();
} }
terminal.focus = () => {}; terminal.focus = () => {
};
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
@@ -221,11 +222,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
? 'ws://localhost:8082' ? 'ws://localhost:8082'
: isElectron() : isElectron()
? (() => { ? (() => {
// Get configured server URL from window object (set by main-axios)
const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081'; 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 wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://';
const wsHost = baseUrl.replace(/^https?:\/\//, ''); // Keep the port const wsHost = baseUrl.replace(/^https?:\/\//, '');
return `${wsProtocol}${wsHost}/ssh/websocket/`; return `${wsProtocol}${wsHost}/ssh/websocket/`;
})() })()
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;

View File

@@ -89,8 +89,6 @@ export function TerminalKeyboard({onSendInput, onLayoutChange}: TerminalKeyboard
navigator.vibrate(20); navigator.vibrate(20);
} }
} catch (e) { } catch (e) {
console.error("Vibration failed:", e);
// Don't show toast for vibration failure as it's not critical
} }
onSendInput(input); onSendInput(input);

View File

@@ -737,17 +737,17 @@ export function HomepageAuth({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="password">{t('common.password')}</Label> <Label htmlFor="password">{t('common.password')}</Label>
<PasswordInput id="password" required className="h-11 text-base" <PasswordInput id="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)} value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/> disabled={loading || internalLoggedIn}/>
</div> </div>
{tab === "signup" && ( {tab === "signup" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label> <Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
<PasswordInput id="signup-confirm-password" required <PasswordInput id="signup-confirm-password" required
className="h-11 text-base" className="h-11 text-base"
value={signupConfirmPassword} value={signupConfirmPassword}
onChange={e => setSignupConfirmPassword(e.target.value)} onChange={e => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}/> disabled={loading || internalLoggedIn}/>
</div> </div>
)} )}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold" <Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"

View File

@@ -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 {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx"; import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
import {BottomNavbar} from "@/ui/Mobile/Navigation/BottomNavbar.tsx"; import {BottomNavbar} from "@/ui/Mobile/Navigation/BottomNavbar.tsx";
@@ -128,7 +128,8 @@ const AppContent: FC = () => {
} }
return ( 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"> <div className="flex-1 min-h-0 relative">
{tabs.map(tab => ( {tabs.map(tab => (
<div <div

View File

@@ -5,7 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react"; import {Server, Terminal} from "lucide-react";
import {getServerStatusById} from "@/ui/main-axios.ts"; import {getServerStatusById} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx"; 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 { export function Host({host, onHostConnect}: HostProps): React.ReactElement {
const {addTab} = useTabs(); const {addTab} = useTabs();

View File

@@ -1,6 +1,6 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import type { TabContextTab } from '../../../types/index.js'; import type {TabContextTab} from '../../../types/index.js';
export type Tab = TabContextTab; export type Tab = TabContextTab;

View File

@@ -1,9 +1,9 @@
import axios, { AxiosError, type AxiosInstance } from 'axios'; import axios, {AxiosError, type AxiosInstance} from 'axios';
import type { import type {
SSHHost, SSHHost,
SSHHostData, SSHHostData,
TunnelConfig, TunnelConfig,
TunnelStatus, TunnelStatus,
Credential, Credential,
CredentialData, CredentialData,
HostInfo, HostInfo,
@@ -11,7 +11,16 @@ import type {
FileManagerFile, FileManagerFile,
FileManagerShortcut FileManagerShortcut
} from '../types/index.js'; } 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 { interface FileManagerOperation {
name: string; name: string;
@@ -119,16 +128,14 @@ export function getCookie(name: string): string | undefined {
function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosInstance { function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosInstance {
const instance = axios.create({ const instance = axios.create({
baseURL, baseURL,
headers: { 'Content-Type': 'application/json' }, headers: {'Content-Type': 'application/json'},
timeout: 30000, timeout: 30000,
}); });
// Request interceptor with enhanced logging
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
const startTime = performance.now(); const startTime = performance.now();
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 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).startTime = startTime;
(config as any).requestId = requestId; (config as any).requestId = requestId;
@@ -144,10 +151,8 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
operation: 'request_start' operation: 'request_start'
}; };
// Get the appropriate logger for this service
const logger = getLoggerForService(serviceName); const logger = getLoggerForService(serviceName);
// Log request start with grouping
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
logger.requestStart(method, fullUrl, context); 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); authLogger.warn('No JWT token found, request will be unauthenticated', context);
} }
// Add Electron-specific headers for OIDC and other backend detection
if (isElectron) { if (isElectron) {
config.headers['X-Electron-App'] = 'true'; config.headers['X-Electron-App'] = 'true';
config.headers['User-Agent'] = 'Termix-Electron/1.6.0'; config.headers['User-Agent'] = 'Termix-Electron/1.6.0';
@@ -167,7 +171,6 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
return config; return config;
}); });
// Response interceptor with comprehensive logging
instance.interceptors.response.use( instance.interceptors.response.use(
(response) => { (response) => {
const endTime = performance.now(); const endTime = performance.now();
@@ -189,15 +192,12 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
operation: 'request_success' operation: 'request_success'
}; };
// Get the appropriate logger for this service
const logger = getLoggerForService(serviceName); const logger = getLoggerForService(serviceName);
// Log successful requests in development
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
logger.requestSuccess(method, fullUrl, response.status, responseTime, context); logger.requestSuccess(method, fullUrl, response.status, responseTime, context);
} }
// Performance logging for slow requests
if (responseTime > 3000) { if (responseTime > 3000) {
logger.warn(`🐌 Slow request: ${responseTime}ms`, context); logger.warn(`🐌 Slow request: ${responseTime}ms`, context);
} }
@@ -228,10 +228,8 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
operation: 'request_error' operation: 'request_error'
}; };
// Get the appropriate logger for this service
const logger = getLoggerForService(serviceName); const logger = getLoggerForService(serviceName);
// Log errors with appropriate method based on error type
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
if (status === 401) { if (status === 401) {
logger.authError(method, fullUrl, context); 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 (status === 401) {
if (isElectron()) { if (isElectron()) {
localStorage.removeItem('jwt'); localStorage.removeItem('jwt');
@@ -264,8 +261,8 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
// ============================================================================ // ============================================================================
const isDev = process.env.NODE_ENV === 'development' && const isDev = process.env.NODE_ENV === 'development' &&
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === ''); (window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
let apiHost = import.meta.env.VITE_API_HOST || 'localhost'; let apiHost = import.meta.env.VITE_API_HOST || 'localhost';
let apiPort = 8081; let apiPort = 8081;
@@ -275,7 +272,6 @@ if (isElectron) {
apiPort = 8081; apiPort = 8081;
} }
// Server configuration management for Electron
export interface ServerConfig { export interface ServerConfig {
serverUrl: string; serverUrl: string;
lastUpdated: string; lastUpdated: string;
@@ -283,7 +279,7 @@ export interface ServerConfig {
export async function getServerConfig(): Promise<ServerConfig | null> { export async function getServerConfig(): Promise<ServerConfig | null> {
if (!isElectron) return null; if (!isElectron) return null;
try { try {
const result = await (window as any).electronAPI?.invoke('get-server-config'); const result = await (window as any).electronAPI?.invoke('get-server-config');
return result; return result;
@@ -295,7 +291,7 @@ export async function getServerConfig(): Promise<ServerConfig | null> {
export async function saveServerConfig(config: ServerConfig): Promise<boolean> { export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
if (!isElectron) return false; if (!isElectron) return false;
try { try {
const result = await (window as any).electronAPI?.invoke('save-server-config', config); const result = await (window as any).electronAPI?.invoke('save-server-config', config);
if (result?.success) { 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 }> { 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 { try {
const result = await (window as any).electronAPI?.invoke('test-server-connection', serverUrl); const result = await (window as any).electronAPI?.invoke('test-server-connection', serverUrl);
return result; return result;
} catch (error) { } catch (error) {
console.error('Failed to test server connection:', 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) { if (isElectron) {
getServerConfig().then(config => { getServerConfig().then(config => {
if (config?.serverUrl) { if (config?.serverUrl) {
@@ -335,12 +330,9 @@ if (isElectron) {
function getApiUrl(path: string, defaultPort: number): string { function getApiUrl(path: string, defaultPort: number): string {
if (isElectron()) { if (isElectron()) {
if (configuredServerUrl) { 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(/\/$/, ''); const baseUrl = configuredServerUrl.replace(/\/$/, '');
return `${baseUrl}${path}`; return `${baseUrl}${path}`;
} }
// In Electron without configured server, return a placeholder that will cause requests to fail gracefully
return 'http://no-server-configured'; return 'http://no-server-configured';
} else if (isDev) { } else if (isDev) {
return `http://${apiHost}:${defaultPort}${path}`; 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) // SSH Host Management API (port 8081)
export let sshHostApi = createApiInstance( export let sshHostApi = createApiInstance(
getApiUrl('/ssh', 8081), getApiUrl('/ssh', 8081),
@@ -362,7 +353,7 @@ export let tunnelApi = createApiInstance(
'TUNNEL' 'TUNNEL'
); );
// File Manager Operations API (port 8084) - SSH file operations // File Manager Operations API (port 8084)
export let fileManagerApi = createApiInstance( export let fileManagerApi = createApiInstance(
getApiUrl('/ssh/file_manager', 8084), getApiUrl('/ssh/file_manager', 8084),
'FILE_MANAGER' 'FILE_MANAGER'
@@ -374,43 +365,31 @@ export let statsApi = createApiInstance(
'STATS' 'STATS'
); );
// Authentication API (port 8081) - includes users, alerts, version, releases // Authentication API (port 8081)
export let authApi = createApiInstance( export let authApi = createApiInstance(
getApiUrl('', 8081), getApiUrl('', 8081),
'AUTH' 'AUTH'
); );
// Function to update API instances with new server configuration
function updateApiInstances() { function updateApiInstances() {
systemLogger.info('Updating API instances with new server configuration', { systemLogger.info('Updating API instances with new server configuration', {
operation: 'api_instance_update', operation: 'api_instance_update',
configuredServerUrl configuredServerUrl
}); });
sshHostApi = createApiInstance(getApiUrl('/ssh', 8081), 'SSH_HOST'); sshHostApi = createApiInstance(getApiUrl('/ssh', 8081), 'SSH_HOST');
tunnelApi = createApiInstance(getApiUrl('/ssh', 8083), 'TUNNEL'); tunnelApi = createApiInstance(getApiUrl('/ssh', 8083), 'TUNNEL');
fileManagerApi = createApiInstance(getApiUrl('/ssh/file_manager', 8084), 'FILE_MANAGER'); fileManagerApi = createApiInstance(getApiUrl('/ssh/file_manager', 8084), 'FILE_MANAGER');
statsApi = createApiInstance(getApiUrl('', 8085), 'STATS'); statsApi = createApiInstance(getApiUrl('', 8085), 'STATS');
authApi = createApiInstance(getApiUrl('', 8081), 'AUTH'); authApi = createApiInstance(getApiUrl('', 8081), 'AUTH');
// Make configuredServerUrl available globally for components that need it // Make configuredServerUrl available globally for components that need it
(window as any).configuredServerUrl = configuredServerUrl; (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 systemLogger.success('All API instances updated successfully', {
function updateApiPorts(port: number) { operation: 'api_instance_update_complete',
systemLogger.info('Updating API instances with new port', { configuredServerUrl
operation: 'api_port_update',
newPort: port
}); });
apiPort = port;
updateApiInstances();
} }
// ============================================================================ // ============================================================================
@@ -440,7 +419,7 @@ function handleApiError(error: unknown, operation: string): never {
const code = error.response?.data?.code; const code = error.response?.data?.code;
const url = error.config?.url; const url = error.config?.url;
const method = error.config?.method?.toUpperCase(); const method = error.config?.method?.toUpperCase();
const errorContext: LogContext = { const errorContext: LogContext = {
...context, ...context,
method, method,
@@ -449,8 +428,7 @@ function handleApiError(error: unknown, operation: string): never {
errorCode: code, errorCode: code,
errorMessage: message errorMessage: message
}; };
// Enhanced error logging with appropriate logger
if (status === 401) { if (status === 401) {
authLogger.warn(`Auth failed: ${method} ${url} - ${message}`, errorContext); authLogger.warn(`Auth failed: ${method} ${url} - ${message}`, errorContext);
throw new ApiError('Authentication required. Please log in again.', 401, 'AUTH_REQUIRED'); 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); throw new ApiError(message || `Failed to ${operation}`, status, code);
} }
} }
if (error instanceof ApiError) { if (error instanceof ApiError) {
throw error; throw error;
} }
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
apiLogger.error(`Unexpected error during ${operation}: ${errorMessage}`, error, context); apiLogger.error(`Unexpected error during ${operation}: ${errorMessage}`, error, context);
throw new ApiError(`Unexpected error during ${operation}: ${errorMessage}`, undefined, 'UNKNOWN_ERROR'); 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(); const formData = new FormData();
formData.append('key', hostData.key); formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData }; const dataWithoutFile = {...submitData};
delete dataWithoutFile.key; delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile)); formData.append('data', JSON.stringify(dataWithoutFile));
const response = await sshHostApi.post('/db/host', formData, { const response = await sshHostApi.post('/db/host', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: {'Content-Type': 'multipart/form-data'},
}); });
return response.data; return response.data;
} else { } else {
@@ -591,12 +569,12 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
const formData = new FormData(); const formData = new FormData();
formData.append('key', hostData.key); formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData }; const dataWithoutFile = {...submitData};
delete dataWithoutFile.key; delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile)); formData.append('data', JSON.stringify(dataWithoutFile));
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, { const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: {'Content-Type': 'multipart/form-data'},
}); });
return response.data; return response.data;
} else { } else {
@@ -615,7 +593,7 @@ export async function bulkImportSSHHosts(hosts: SSHHostData[]): Promise<{
errors: string[]; errors: string[];
}> { }> {
try { try {
const response = await sshHostApi.post('/bulk-import', { hosts }); const response = await sshHostApi.post('/bulk-import', {hosts});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'bulk import SSH hosts'); 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> { export async function disconnectTunnel(tunnelName: string): Promise<any> {
try { try {
const response = await tunnelApi.post('/tunnel/disconnect', { tunnelName }); const response = await tunnelApi.post('/tunnel/disconnect', {tunnelName});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'disconnect tunnel'); handleApiError(error, 'disconnect tunnel');
@@ -678,7 +656,7 @@ export async function disconnectTunnel(tunnelName: string): Promise<any> {
export async function cancelTunnel(tunnelName: string): Promise<any> { export async function cancelTunnel(tunnelName: string): Promise<any> {
try { try {
const response = await tunnelApi.post('/tunnel/cancel', { tunnelName }); const response = await tunnelApi.post('/tunnel/cancel', {tunnelName});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'cancel tunnel'); handleApiError(error, 'cancel tunnel');
@@ -709,7 +687,7 @@ export async function addFileManagerRecent(file: FileManagerOperation): Promise<
export async function removeFileManagerRecent(file: FileManagerOperation): Promise<any> { export async function removeFileManagerRecent(file: FileManagerOperation): Promise<any> {
try { try {
const response = await sshHostApi.delete('/file_manager/recent', { data: file }); const response = await sshHostApi.delete('/file_manager/recent', {data: file});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'remove recent file'); handleApiError(error, 'remove recent file');
@@ -736,7 +714,7 @@ export async function addFileManagerPinned(file: FileManagerOperation): Promise<
export async function removeFileManagerPinned(file: FileManagerOperation): Promise<any> { export async function removeFileManagerPinned(file: FileManagerOperation): Promise<any> {
try { try {
const response = await sshHostApi.delete('/file_manager/pinned', { data: file }); const response = await sshHostApi.delete('/file_manager/pinned', {data: file});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'remove pinned file'); handleApiError(error, 'remove pinned file');
@@ -763,7 +741,7 @@ export async function addFileManagerShortcut(shortcut: FileManagerOperation): Pr
export async function removeFileManagerShortcut(shortcut: FileManagerOperation): Promise<any> { export async function removeFileManagerShortcut(shortcut: FileManagerOperation): Promise<any> {
try { try {
const response = await sshHostApi.delete('/file_manager/shortcuts', { data: shortcut }); const response = await sshHostApi.delete('/file_manager/shortcuts', {data: shortcut});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'remove shortcut'); handleApiError(error, 'remove shortcut');
@@ -799,7 +777,7 @@ export async function connectSSH(sessionId: string, config: {
export async function disconnectSSH(sessionId: string): Promise<any> { export async function disconnectSSH(sessionId: string): Promise<any> {
try { try {
const response = await fileManagerApi.post('/ssh/disconnect', { sessionId }); const response = await fileManagerApi.post('/ssh/disconnect', {sessionId});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'disconnect SSH'); 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 }> { export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
try { try {
const response = await fileManagerApi.get('/ssh/status', { const response = await fileManagerApi.get('/ssh/status', {
params: { sessionId } params: {sessionId}
}); });
return response.data; return response.data;
} catch (error) { } 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[]> { export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
try { try {
const response = await fileManagerApi.get('/ssh/listFiles', { const response = await fileManagerApi.get('/ssh/listFiles', {
params: { sessionId, path } params: {sessionId, path}
}); });
return response.data || []; return response.data || [];
} catch (error) { } 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 }> { export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
try { try {
const response = await fileManagerApi.get('/ssh/readFile', { const response = await fileManagerApi.get('/ssh/readFile', {
params: { sessionId, path } params: {sessionId, path}
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -975,7 +953,7 @@ export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
export async function registerUser(username: string, password: string): Promise<any> { export async function registerUser(username: string, password: string): Promise<any> {
try { try {
const response = await authApi.post('/users/create', { username, password }); const response = await authApi.post('/users/create', {username, password});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'register user'); 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> { export async function loginUser(username: string, password: string): Promise<AuthResponse> {
try { try {
const response = await authApi.post('/users/login', { username, password }); const response = await authApi.post('/users/login', {username, password});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'login user'); handleApiError(error, 'login user');
@@ -1015,7 +993,6 @@ export async function getOIDCConfig(): Promise<any> {
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.warn('Failed to fetch OIDC config:', error.response?.data?.error || error.message); 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; return null;
} }
} }
@@ -1031,7 +1008,7 @@ export async function getUserCount(): Promise<UserCount> {
export async function initiatePasswordReset(username: string): Promise<any> { export async function initiatePasswordReset(username: string): Promise<any> {
try { try {
const response = await authApi.post('/users/initiate-reset', { username }); const response = await authApi.post('/users/initiate-reset', {username});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'initiate password reset'); 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> { export async function verifyPasswordResetCode(username: string, resetCode: string): Promise<any> {
try { 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; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'verify reset code'); 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> { export async function completePasswordReset(username: string, tempToken: string, newPassword: string): Promise<any> {
try { 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; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'complete password reset'); handleApiError(error, 'complete password reset');
@@ -1080,7 +1057,7 @@ export async function getUserList(): Promise<{ users: UserInfo[] }> {
export async function makeUserAdmin(username: string): Promise<any> { export async function makeUserAdmin(username: string): Promise<any> {
try { try {
const response = await authApi.post('/users/make-admin', { username }); const response = await authApi.post('/users/make-admin', {username});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'make user admin'); 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> { export async function removeAdminStatus(username: string): Promise<any> {
try { try {
const response = await authApi.post('/users/remove-admin', { username }); const response = await authApi.post('/users/remove-admin', {username});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'remove admin status'); 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> { export async function deleteUser(username: string): Promise<any> {
try { try {
const response = await authApi.delete('/users/delete-user', { data: { username } }); const response = await authApi.delete('/users/delete-user', {data: {username}});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'delete user'); handleApiError(error, 'delete user');
@@ -1107,7 +1084,7 @@ export async function deleteUser(username: string): Promise<any> {
export async function deleteAccount(password: string): Promise<any> { export async function deleteAccount(password: string): Promise<any> {
try { try {
const response = await authApi.delete('/users/delete-account', { data: { password } }); const response = await authApi.delete('/users/delete-account', {data: {password}});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'delete account'); handleApiError(error, 'delete account');
@@ -1116,7 +1093,7 @@ export async function deleteAccount(password: string): Promise<any> {
export async function updateRegistrationAllowed(allowed: boolean): Promise<any> { export async function updateRegistrationAllowed(allowed: boolean): Promise<any> {
try { try {
const response = await authApi.patch('/users/registration-allowed', { allowed }); const response = await authApi.patch('/users/registration-allowed', {allowed});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'update registration allowed'); 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[] }> { export async function enableTOTP(totp_code: string): Promise<{ message: string; backup_codes: string[] }> {
try { try {
const response = await authApi.post('/users/totp/enable', { totp_code }); const response = await authApi.post('/users/totp/enable', {totp_code});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error as AxiosError, 'enable TOTP'); 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 }> { export async function disableTOTP(password?: string, totp_code?: string): Promise<{ message: string }> {
try { 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; return response.data;
} catch (error) { } catch (error) {
handleApiError(error as AxiosError, 'disable TOTP'); 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> { export async function verifyTOTPLogin(temp_token: string, totp_code: string): Promise<AuthResponse> {
try { 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; return response.data;
} catch (error) { } catch (error) {
handleApiError(error as AxiosError, 'verify TOTP login'); 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[] }> { export async function generateBackupCodes(password?: string, totp_code?: string): Promise<{ backup_codes: string[] }> {
try { 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; return response.data;
} catch (error) { } catch (error) {
handleApiError(error as AxiosError, 'generate backup codes'); 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> { export async function dismissAlert(userId: string, alertId: string): Promise<any> {
try { try {
const response = await authApi.post('/alerts/dismiss', { userId, alertId }); const response = await authApi.post('/alerts/dismiss', {userId, alertId});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'dismiss alert'); handleApiError(error, 'dismiss alert');
@@ -1328,7 +1305,7 @@ export async function getSSHHostWithCredentials(hostId: number): Promise<any> {
// Apply credential to SSH host // Apply credential to SSH host
export async function applyCredentialToHost(hostId: number, credentialId: number): Promise<any> { export async function applyCredentialToHost(hostId: number, credentialId: number): Promise<any> {
try { 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; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'apply credential to host'); handleApiError(error, 'apply credential to host');
@@ -1348,7 +1325,7 @@ export async function removeCredentialFromHost(hostId: number): Promise<any> {
// Migrate host to managed credential // Migrate host to managed credential
export async function migrateHostToCredential(hostId: number, credentialName: string): Promise<any> { export async function migrateHostToCredential(hostId: number, credentialName: string): Promise<any> {
try { 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; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, 'migrate host to credential'); handleApiError(error, 'migrate host to credential');