diff --git a/.env b/.env index 955cc3f4..8c58d0d4 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=1.2 +VERSION=1.3.0 \ No newline at end of file diff --git a/README.md b/README.md index 03dca564..18e8db10 100644 --- a/README.md +++ b/README.md @@ -13,32 +13,31 @@ [![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#) [![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#) -

- Termix Banner + Termix Banner

If you would like, you can support the project here!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Overview -Termix 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 configuration editing, with many more tools to come. +Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file editing, with many more tools to come. # Features - **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system - **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring -- **Remote Config Editor** - Edit files directly on remote servers with syntax highlighting and file management +- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files) - **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders +- **Server Stats** - View CPU, memory, and HDD usage on any SSH server - **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned -- **Modern UI** - Clean interface built with React, Tailwind CSS, and the amazing Shadcn +- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn # Planned Features - **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc - **More auth types** - Add 2FA, TOTP, etc - **Theming** - Modify themeing for all tools -- **Improved SFTP Support** - Ability to manage files easier with the config editor by uploading, creating, and removing files - **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue) - **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone @@ -79,7 +78,7 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG

-

diff --git a/docker/Dockerfile b/docker/Dockerfile index 0e88425f..c82aa3e6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -72,7 +72,7 @@ RUN chown -R node:node /app VOLUME ["/app/data"] -EXPOSE ${PORT} 8081 8082 8083 8084 +EXPOSE ${PORT} 8081 8082 8083 8084 8085 COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/docker/nginx.conf b/docker/nginx.conf index c332661a..fe530ac4 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -85,7 +85,36 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - location /ssh/config_editor/ { + # File manager recent, pinned, shortcuts (handled by SSH service) + location /ssh/file_manager/recent { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ssh/file_manager/pinned { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ssh/file_manager/shortcuts { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SSH file manager operations (handled by file manager service) + location /ssh/file_manager/ssh/ { proxy_pass http://127.0.0.1:8084; proxy_http_version 1.1; proxy_set_header Host $host; @@ -94,6 +123,24 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /status/ { + proxy_pass http://127.0.0.1:8085; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /metrics/ { + proxy_pass http://127.0.0.1:8085; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; diff --git a/package-lock.json b/package-lock.json index 262b5166..dc2db2ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,11 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", @@ -24,7 +25,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-tooltip": "^1.2.7", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", "@types/multer": "^2.0.0", @@ -55,12 +56,14 @@ "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", + "next-themes": "^0.4.6", "node-fetch": "^3.3.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", + "sonner": "^2.0.7", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", @@ -1739,20 +1742,20 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", - "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -1774,6 +1777,78 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2106,6 +2181,30 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", @@ -2345,19 +2444,19 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", - "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -2378,6 +2477,95 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -6784,6 +6972,16 @@ "node": ">= 0.6" } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-abi": { "version": "3.75.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", @@ -7691,6 +7889,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 16eabebe..e53653c2 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,11 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", @@ -28,7 +29,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-tooltip": "^1.2.7", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", "@types/multer": "^2.0.0", @@ -59,12 +60,14 @@ "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", + "next-themes": "^0.4.6", "node-fetch": "^3.3.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", + "sonner": "^2.0.7", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", diff --git a/repo-images/HeaderImage.png b/repo-images/HeaderImage.png new file mode 100644 index 00000000..74892b03 Binary files /dev/null and b/repo-images/HeaderImage.png differ diff --git a/repo-images/Image 1.png b/repo-images/Image 1.png index 6e4432ee..a79728e8 100644 Binary files a/repo-images/Image 1.png and b/repo-images/Image 1.png differ diff --git a/repo-images/Image 2.png b/repo-images/Image 2.png index 029a952d..8caa89d5 100644 Binary files a/repo-images/Image 2.png and b/repo-images/Image 2.png differ diff --git a/repo-images/Image 3.png b/repo-images/Image 3.png index f48b9e90..495187fb 100644 Binary files a/repo-images/Image 3.png and b/repo-images/Image 3.png differ diff --git a/repo-images/Image 4.png b/repo-images/Image 4.png index a06a6c2a..c30f6577 100644 Binary files a/repo-images/Image 4.png and b/repo-images/Image 4.png differ diff --git a/repo-images/Image 5.png b/repo-images/Image 5.png index 1a3290ae..65c199af 100644 Binary files a/repo-images/Image 5.png and b/repo-images/Image 5.png differ diff --git a/src/App.tsx b/src/App.tsx index c4510ae9..cb2881da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,72 @@ -import React from "react" +import React, {useState, useEffect} from "react" +import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx" +import {Homepage} from "@/ui/Homepage/Homepage.tsx" +import {AppView} from "@/ui/Navigation/AppView.tsx" +import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx" +import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx" +import axios from "axios" +import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx"; +import { AdminSettings } from "@/ui/Admin/AdminSettings"; +import { Toaster } from "@/components/ui/sonner"; -import {Homepage} from "@/apps/Homepage/Homepage.tsx" -import {Terminal} from "@/apps/SSH/Terminal/Terminal.tsx" -import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx"; -import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx"; -import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx" +const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; +const API = axios.create({baseURL: apiBase}); -function App() { - const [view, setView] = React.useState("homepage") - const [mountedViews, setMountedViews] = React.useState>(new Set(["homepage"])) +function getCookie(name: string) { + return document.cookie.split('; ').reduce((r, v) => { + const parts = v.split('='); + return parts[0] === name ? decodeURIComponent(parts[1]) : r; + }, ""); +} + +function setCookie(name: string, value: string, days = 7) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; +} + +function AppContent() { + const [view, setView] = useState("homepage") + const [mountedViews, setMountedViews] = useState>(new Set(["homepage"])) + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [username, setUsername] = useState(null) + const [isAdmin, setIsAdmin] = useState(false) + const [authLoading, setAuthLoading] = useState(true) + const [isTopbarOpen, setIsTopbarOpen] = useState(true) + const {currentTab, tabs} = useTabs(); + + useEffect(() => { + const checkAuth = () => { + const jwt = getCookie("jwt"); + if (jwt) { + setAuthLoading(true); + API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}) + .then((meRes) => { + setIsAuthenticated(true); + setIsAdmin(!!meRes.data.is_admin); + setUsername(meRes.data.username || null); + }) + .catch((err) => { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + }) + .finally(() => setAuthLoading(false)); + } else { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + setAuthLoading(false); + } + } + + checkAuth() + + const handleStorageChange = () => checkAuth() + window.addEventListener('storage', handleStorageChange) + + return () => window.removeEventListener('storage', handleStorageChange) + }, []) const handleSelectView = (nextView: string) => { setMountedViews((prev) => { @@ -20,37 +78,150 @@ function App() { setView(nextView) } + const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => { + setIsAuthenticated(true) + setIsAdmin(authData.isAdmin) + setUsername(authData.username) + } + + const currentTabData = tabs.find(tab => tab.id === currentTab); + const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager'; + const showHome = currentTabData?.type === 'home'; + const showSshManager = currentTabData?.type === 'ssh_manager'; + const showAdmin = currentTabData?.type === 'admin'; + return ( -
-
- {mountedViews.has("homepage") && ( -
- +
+ {!isAuthenticated && !authLoading && ( +
+ +
+ +
+ + + + )} +
) } +function App() { + return ( + + + + ); +} + export default App \ No newline at end of file diff --git a/src/apps/Homepage/Homepage.tsx b/src/apps/Homepage/Homepage.tsx deleted file mode 100644 index 299203fd..00000000 --- a/src/apps/Homepage/Homepage.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx"; -import React, {useEffect, useState} from "react"; -import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx"; -import axios from "axios"; -import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx"; -import {AlertManager} from "@/apps/Homepage/AlertManager.tsx"; - -interface HomepageProps { - onSelectView: (view: string) => void; -} - -function getCookie(name: string) { - return document.cookie.split('; ').reduce((r, v) => { - const parts = v.split('='); - return parts[0] === name ? decodeURIComponent(parts[1]) : r; - }, ""); -} - -function setCookie(name: string, value: string, days = 7) { - const expires = new Date(Date.now() + days * 864e5).toUTCString(); - document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; -} - -const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; - -const API = axios.create({ - baseURL: apiBase, -}); - -export function Homepage({onSelectView}: HomepageProps): React.ReactElement { - const [loggedIn, setLoggedIn] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [username, setUsername] = useState(null); - const [userId, setUserId] = useState(null); - const [authLoading, setAuthLoading] = useState(true); - const [dbError, setDbError] = useState(null); - - useEffect(() => { - const jwt = getCookie("jwt"); - - if (jwt) { - setAuthLoading(true); - Promise.all([ - API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}), - API.get("/db-health") - ]) - .then(([meRes]) => { - setLoggedIn(true); - setIsAdmin(!!meRes.data.is_admin); - setUsername(meRes.data.username || null); - setUserId(meRes.data.userId || null); - setDbError(null); - }) - .catch((err) => { - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - setCookie("jwt", "", -1); - if (err?.response?.data?.error?.includes("Database")) { - setDbError("Could not connect to the database. Please try again later."); - } else { - setDbError(null); - } - }) - .finally(() => setAuthLoading(false)); - } else { - setAuthLoading(false); - } - }, []); - - return ( - -
-
- - -
- - {/* Alert Manager - replaces the old welcome card */} - -
-
- ); -} \ No newline at end of file diff --git a/src/apps/Homepage/HomepageAuth.tsx b/src/apps/Homepage/HomepageAuth.tsx deleted file mode 100644 index e3e62a0c..00000000 --- a/src/apps/Homepage/HomepageAuth.tsx +++ /dev/null @@ -1,723 +0,0 @@ -import React, {useState, useEffect} from "react"; -import {cn} from "@/lib/utils"; -import {Button} from "@/components/ui/button"; -import {Input} from "@/components/ui/input"; -import {Label} from "@/components/ui/label"; -import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert"; -import {Separator} from "@/components/ui/separator"; -import axios from "axios"; - -function setCookie(name: string, value: string, days = 7) { - const expires = new Date(Date.now() + days * 864e5).toUTCString(); - document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; -} - -function getCookie(name: string) { - return document.cookie.split('; ').reduce((r, v) => { - const parts = v.split('='); - return parts[0] === name ? decodeURIComponent(parts[1]) : r; - }, ""); -} - -const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; - -const API = axios.create({ - baseURL: apiBase, -}); - -interface HomepageAuthProps extends React.ComponentProps<"div"> { - setLoggedIn: (loggedIn: boolean) => void; - setIsAdmin: (isAdmin: boolean) => void; - setUsername: (username: string | null) => void; - setUserId: (userId: string | null) => void; - loggedIn: boolean; - authLoading: boolean; - dbError: string | null; - setDbError: (error: string | null) => void; -} - -export function HomepageAuth({ - className, - setLoggedIn, - setIsAdmin, - setUsername, - setUserId, - loggedIn, - authLoading, - dbError, - setDbError, - ...props - }: HomepageAuthProps) { - const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login"); - const [localUsername, setLocalUsername] = useState(""); - const [password, setPassword] = useState(""); - const [signupConfirmPassword, setSignupConfirmPassword] = useState(""); - const [loading, setLoading] = useState(false); - const [oidcLoading, setOidcLoading] = useState(false); - const [error, setError] = useState(null); - const [internalLoggedIn, setInternalLoggedIn] = useState(false); - const [firstUser, setFirstUser] = useState(false); - const [registrationAllowed, setRegistrationAllowed] = useState(true); - const [oidcConfigured, setOidcConfigured] = useState(false); - - const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); - const [resetCode, setResetCode] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [tempToken, setTempToken] = useState(""); - const [resetLoading, setResetLoading] = useState(false); - const [resetSuccess, setResetSuccess] = useState(false); - - useEffect(() => { - setInternalLoggedIn(loggedIn); - }, [loggedIn]); - - useEffect(() => { - API.get("/registration-allowed").then(res => { - setRegistrationAllowed(res.data.allowed); - }); - }, []); - - useEffect(() => { - API.get("/oidc-config").then((response) => { - if (response.data) { - setOidcConfigured(true); - } else { - setOidcConfigured(false); - } - }).catch((error) => { - if (error.response?.status === 404) { - setOidcConfigured(false); - } else { - setOidcConfigured(false); - } - }); - }, []); - - useEffect(() => { - API.get("/count").then(res => { - if (res.data.count === 0) { - setFirstUser(true); - setTab("signup"); - } else { - setFirstUser(false); - } - setDbError(null); - }).catch(() => { - setDbError("Could not connect to the database. Please try again later."); - }); - }, [setDbError]); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setLoading(true); - - if (!localUsername.trim()) { - setError("Username is required"); - setLoading(false); - return; - } - - try { - let res, meRes; - if (tab === "login") { - res = await API.post("/login", {username: localUsername, password}); - } else { - if (password !== signupConfirmPassword) { - setError("Passwords do not match"); - setLoading(false); - return; - } - if (password.length < 6) { - setError("Password must be at least 6 characters long"); - setLoading(false); - return; - } - await API.post("/create", {username: localUsername, password}); - res = await API.post("/login", {username: localUsername, password}); - } - setCookie("jwt", res.data.token); - [meRes] = await Promise.all([ - API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}), - API.get("/db-health") - ]); - setInternalLoggedIn(true); - setLoggedIn(true); - setIsAdmin(!!meRes.data.is_admin); - setUsername(meRes.data.username || null); - setUserId(meRes.data.id || null); - setDbError(null); - if (tab === "signup") { - setSignupConfirmPassword(""); - } - } catch (err: any) { - setError(err?.response?.data?.error || "Unknown error"); - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - setCookie("jwt", "", -1); - if (err?.response?.data?.error?.includes("Database")) { - setDbError("Could not connect to the database. Please try again later."); - } else { - setDbError(null); - } - } finally { - setLoading(false); - } - } - - async function initiatePasswordReset() { - setError(null); - setResetLoading(true); - try { - await API.post("/initiate-reset", {username: localUsername}); - setResetStep("verify"); - setError(null); - } catch (err: any) { - setError(err?.response?.data?.error || "Failed to initiate password reset"); - } finally { - setResetLoading(false); - } - } - - async function verifyResetCode() { - setError(null); - setResetLoading(true); - try { - const response = await API.post("/verify-reset-code", { - username: localUsername, - resetCode: resetCode - }); - setTempToken(response.data.tempToken); - setResetStep("newPassword"); - setError(null); - } catch (err: any) { - setError(err?.response?.data?.error || "Failed to verify reset code"); - } finally { - setResetLoading(false); - } - } - - async function completePasswordReset() { - setError(null); - setResetLoading(true); - - if (newPassword !== confirmPassword) { - setError("Passwords do not match"); - setResetLoading(false); - return; - } - - if (newPassword.length < 6) { - setError("Password must be at least 6 characters long"); - setResetLoading(false); - return; - } - - try { - await API.post("/complete-reset", { - username: localUsername, - tempToken: tempToken, - newPassword: newPassword - }); - - setResetStep("initiate"); - setResetCode(""); - setNewPassword(""); - setConfirmPassword(""); - setTempToken(""); - setError(null); - - setResetSuccess(true); - } catch (err: any) { - setError(err?.response?.data?.error || "Failed to complete password reset"); - } finally { - setResetLoading(false); - } - } - - function resetPasswordState() { - setResetStep("initiate"); - setResetCode(""); - setNewPassword(""); - setConfirmPassword(""); - setTempToken(""); - setError(null); - setResetSuccess(false); - setSignupConfirmPassword(""); - } - - function clearFormFields() { - setPassword(""); - setSignupConfirmPassword(""); - setError(null); - } - - async function resetPassword() { - - } - - async function handleOIDCLogin() { - setError(null); - setOidcLoading(true); - try { - const authResponse = await API.get("/oidc/authorize"); - const {auth_url: authUrl} = authResponse.data; - - if (!authUrl || authUrl === 'undefined') { - throw new Error('Invalid authorization URL received from backend'); - } - - window.location.replace(authUrl); - } catch (err: any) { - setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login"); - setOidcLoading(false); - } - } - - useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const success = urlParams.get('success'); - const token = urlParams.get('token'); - const error = urlParams.get('error'); - - if (error) { - setError(`OIDC authentication failed: ${error}`); - setOidcLoading(false); - window.history.replaceState({}, document.title, window.location.pathname); - return; - } - - if (success && token) { - setOidcLoading(true); - setError(null); - - setCookie("jwt", token); - API.get("/me", {headers: {Authorization: `Bearer ${token}`}}) - .then(meRes => { - setInternalLoggedIn(true); - setLoggedIn(true); - setIsAdmin(!!meRes.data.is_admin); - setUsername(meRes.data.username || null); - setUserId(meRes.data.id || null); - setDbError(null); - window.history.replaceState({}, document.title, window.location.pathname); - }) - .catch(err => { - setError("Failed to get user info after OIDC login"); - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - setCookie("jwt", "", -1); - window.history.replaceState({}, document.title, window.location.pathname); - }) - .finally(() => { - setOidcLoading(false); - }); - } - }, []); - - const Spinner = ( - - - - - ); - - return ( -
-
- {dbError && ( - - Error - {dbError} - - )} - {firstUser && !dbError && !internalLoggedIn && ( - - First User - - You are the first user and will be made an admin. You can view admin settings in the sidebar - user dropdown. If you think this is a mistake, check the docker logs, or create a{" "} - - GitHub issue - . - - - )} - {!registrationAllowed && !internalLoggedIn && ( - - Registration Disabled - - New account registration is currently disabled by an admin. Please log in or contact an - administrator. - - - )} - {(internalLoggedIn || (authLoading && getCookie("jwt"))) && ( -
- - Logged in! - - You are logged in! Use the sidebar to access all available tools. To get started, - create an SSH Host in the SSH Manager tab. Once created, you can connect to that - host using the other apps in the sidebar. - - - -
- -
- -
- -
- -
-
- )} - {(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && ( - <> -
- - - {oidcConfigured && ( - - )} -
-
-

- {tab === "login" ? "Login to your account" : - tab === "signup" ? "Create a new account" : - tab === "external" ? "Login with external provider" : - "Reset your password"} -

-
- - {tab === "external" || tab === "reset" ? ( -
- {tab === "external" && ( - <> -
-

Login using your configured external identity provider

-
- - - )} - {tab === "reset" && ( - <> - {resetStep === "initiate" && ( - <> -
-

Enter your username to receive a password reset code. The code - will be logged in the docker container logs.

-
-
-
- - setLocalUsername(e.target.value)} - disabled={resetLoading} - /> -
- -
- - )} - - {resetStep === "verify" && ( - <> -
-

Enter the 6-digit code from the docker container logs for - user: {localUsername}

-
-
-
- - setResetCode(e.target.value.replace(/\D/g, ''))} - disabled={resetLoading} - placeholder="000000" - /> -
- - -
- - )} - - {resetSuccess && ( - <> - - Success! - - Your password has been successfully reset! You can now log in - with your new password. - - - - - )} - - {resetStep === "newPassword" && !resetSuccess && ( - <> -
-

Enter your new password for - user: {localUsername}

-
-
-
- - setNewPassword(e.target.value)} - disabled={resetLoading} - /> -
-
- - setConfirmPassword(e.target.value)} - disabled={resetLoading} - /> -
- - -
- - )} - - )} -
- ) : ( -
-
- - setLocalUsername(e.target.value)} - disabled={loading || internalLoggedIn} - /> -
-
- - setPassword(e.target.value)} - disabled={loading || internalLoggedIn}/> -
- {tab === "signup" && ( -
- - setSignupConfirmPassword(e.target.value)} - disabled={loading || internalLoggedIn}/> -
- )} - - {tab === "login" && ( - - )} -
- )} - - )} - {error && ( - - Error - {error} - - )} -
-
- ); -} \ No newline at end of file diff --git a/src/apps/Homepage/HomepageSidebar.tsx b/src/apps/Homepage/HomepageSidebar.tsx deleted file mode 100644 index 45a60801..00000000 --- a/src/apps/Homepage/HomepageSidebar.tsx +++ /dev/null @@ -1,898 +0,0 @@ -import React from 'react'; -import { - Computer, - Server, - File, - Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings -} from "lucide-react"; - -import { - Sidebar, - SidebarContent, SidebarFooter, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, SidebarProvider, SidebarInset, -} from "@/components/ui/sidebar.tsx" - -import { - Separator, -} from "@/components/ui/separator.tsx" -import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, - SheetTrigger, - SheetClose -} from "@/components/ui/sheet"; -import {Checkbox} from "@/components/ui/checkbox.tsx"; -import {Input} from "@/components/ui/input.tsx"; -import {Label} from "@/components/ui/label.tsx"; -import {Button} from "@/components/ui/button.tsx"; -import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx"; -import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table.tsx"; -import axios from "axios"; - -interface SidebarProps { - onSelectView: (view: string) => void; - getView?: () => string; - disabled?: boolean; - isAdmin?: boolean; - username?: string | null; - children?: React.ReactNode; -} - -function handleLogout() { - document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; - window.location.reload(); -} - -function getCookie(name: string) { - return document.cookie.split('; ').reduce((r, v) => { - const parts = v.split('='); - return parts[0] === name ? decodeURIComponent(parts[1]) : r; - }, ""); -} - -const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; - -const API = axios.create({ - baseURL: apiBase, -}); - -export function HomepageSidebar({ - onSelectView, - getView, - disabled, - isAdmin, - username, - children, - }: SidebarProps): React.ReactElement { - const [adminSheetOpen, setAdminSheetOpen] = React.useState(false); - const [allowRegistration, setAllowRegistration] = React.useState(true); - const [regLoading, setRegLoading] = React.useState(false); - const [oidcConfig, setOidcConfig] = React.useState({ - client_id: '', - client_secret: '', - issuer_url: '', - authorization_url: '', - token_url: '', - identifier_path: 'sub', - name_path: 'name', - scopes: 'openid email profile' - }); - const [oidcLoading, setOidcLoading] = React.useState(false); - const [oidcError, setOidcError] = React.useState(null); - const [oidcSuccess, setOidcSuccess] = React.useState(null); - - const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); - const [deletePassword, setDeletePassword] = React.useState(""); - const [deleteLoading, setDeleteLoading] = React.useState(false); - const [deleteError, setDeleteError] = React.useState(null); - const [adminCount, setAdminCount] = React.useState(0); - - const [users, setUsers] = React.useState>([]); - const [usersLoading, setUsersLoading] = React.useState(false); - const [newAdminUsername, setNewAdminUsername] = React.useState(""); - const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); - const [makeAdminError, setMakeAdminError] = React.useState(null); - const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); - - React.useEffect(() => { - if (adminSheetOpen) { - const jwt = getCookie("jwt"); - if (jwt && isAdmin) { - API.get("/oidc-config").then(res => { - if (res.data) { - setOidcConfig(res.data); - } - }).catch((error) => { - }); - fetchUsers(); - } - } else { - const jwt = getCookie("jwt"); - if (jwt && isAdmin) { - fetchAdminCount(); - } - } - }, [adminSheetOpen, isAdmin]); - - React.useEffect(() => { - if (!isAdmin) { - setAdminSheetOpen(false); - setUsers([]); - setAdminCount(0); - } - }, [isAdmin]); - - const handleToggle = async (checked: boolean) => { - if (!isAdmin) { - return; - } - - setRegLoading(true); - const jwt = getCookie("jwt"); - try { - await API.patch( - "/registration-allowed", - {allowed: checked}, - {headers: {Authorization: `Bearer ${jwt}`}} - ); - setAllowRegistration(checked); - } catch (e) { - } finally { - setRegLoading(false); - } - }; - - const handleOIDCConfigSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!isAdmin) { - return; - } - - setOidcLoading(true); - setOidcError(null); - setOidcSuccess(null); - - const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url']; - const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]); - - if (missingFields.length > 0) { - setOidcError(`Missing required fields: ${missingFields.join(', ')}`); - setOidcLoading(false); - return; - } - - const jwt = getCookie("jwt"); - try { - await API.post( - "/oidc-config", - oidcConfig, - {headers: {Authorization: `Bearer ${jwt}`}} - ); - setOidcSuccess("OIDC configuration updated successfully!"); - } catch (err: any) { - setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration"); - } finally { - setOidcLoading(false); - } - }; - - const handleOIDCConfigChange = (field: string, value: string) => { - setOidcConfig(prev => ({ - ...prev, - [field]: value - })); - }; - - const handleDeleteAccount = async (e: React.FormEvent) => { - e.preventDefault(); - setDeleteLoading(true); - setDeleteError(null); - - if (!deletePassword.trim()) { - setDeleteError("Password is required"); - setDeleteLoading(false); - return; - } - - const jwt = getCookie("jwt"); - try { - await API.delete("/delete-account", { - headers: {Authorization: `Bearer ${jwt}`}, - data: {password: deletePassword} - }); - - handleLogout(); - } catch (err: any) { - setDeleteError(err?.response?.data?.error || "Failed to delete account"); - setDeleteLoading(false); - } - }; - - const fetchUsers = async () => { - const jwt = getCookie("jwt"); - - if (!jwt || !isAdmin) { - return; - } - - setUsersLoading(true); - try { - const response = await API.get("/list", { - headers: {Authorization: `Bearer ${jwt}`} - }); - setUsers(response.data.users); - - const adminUsers = response.data.users.filter((user: any) => user.is_admin); - setAdminCount(adminUsers.length); - } catch (err: any) { - console.error("Failed to fetch users:", err); - } finally { - setUsersLoading(false); - } - }; - - const fetchAdminCount = async () => { - const jwt = getCookie("jwt"); - - if (!jwt || !isAdmin) { - return; - } - - try { - const response = await API.get("/list", { - headers: {Authorization: `Bearer ${jwt}`} - }); - const adminUsers = response.data.users.filter((user: any) => user.is_admin); - setAdminCount(adminUsers.length); - } catch (err: any) { - console.error("Failed to fetch admin count:", err); - } - }; - - const makeUserAdmin = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newAdminUsername.trim()) return; - - if (!isAdmin) { - return; - } - - setMakeAdminLoading(true); - setMakeAdminError(null); - setMakeAdminSuccess(null); - - const jwt = getCookie("jwt"); - try { - await API.post("/make-admin", - {username: newAdminUsername.trim()}, - {headers: {Authorization: `Bearer ${jwt}`}} - ); - setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`); - setNewAdminUsername(""); - fetchUsers(); - } catch (err: any) { - setMakeAdminError(err?.response?.data?.error || "Failed to make user admin"); - } finally { - setMakeAdminLoading(false); - } - }; - - const removeAdminStatus = async (username: string) => { - if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return; - - if (!isAdmin) { - return; - } - - const jwt = getCookie("jwt"); - try { - await API.post("/remove-admin", - {username}, - {headers: {Authorization: `Bearer ${jwt}`}} - ); - fetchUsers(); - } catch (err: any) { - console.error("Failed to remove admin status:", err); - } - }; - - const deleteUser = async (username: string) => { - if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return; - - if (!isAdmin) { - return; - } - - const jwt = getCookie("jwt"); - try { - await API.delete("/delete-user", { - headers: {Authorization: `Bearer ${jwt}`}, - data: {username} - }); - fetchUsers(); - } catch (err: any) { - console.error("Failed to delete user:", err); - } - }; - - return ( -
- - - - - - Termix - - - - - - onSelectView("ssh_manager")} - disabled={disabled}> - - SSH Manager - - -
- - onSelectView("terminal")} - disabled={disabled}> - - Terminal - - - - onSelectView("tunnel")} - disabled={disabled}> - - Tunnel - - - - onSelectView("config_editor")} - disabled={disabled}> - - Config Editor - - -
- - window.open("https://dashix.dev", "_blank")} - disabled={disabled}> - - Tools - - -
-
-
-
- - - - - - - - {username ? username : 'Signed out'} - - - - - {isAdmin && ( - { - if (isAdmin) { - setAdminSheetOpen(true); - } - }}> - Admin Settings - - )} - - Sign out - - setDeleteAccountOpen(true)} - disabled={isAdmin && adminCount <= 1} - > - - Delete Account - {isAdmin && adminCount <= 1 && " (Last Admin)"} - - - - - - - - {/* Admin Settings Sheet */} - {isAdmin && ( - { - if (open && !isAdmin) return; - setAdminSheetOpen(open); - }}> - - - Admin Settings - - -
- - - - - Reg - - - - OIDC - - - - Users - - - - Admins - - - - {/* Registration Settings Tab */} - -
-

User Registration

- -
-
- - {/* OIDC Configuration Tab */} - -
-

External Authentication - (OIDC)

-

- Configure external identity provider for OIDC/OAuth2 authentication. - Users will see an "External" login option once configured. -

- - {oidcError && ( - - Error - {oidcError} - - )} - -
-
- - handleOIDCConfigChange('client_id', e.target.value)} - placeholder="your-client-id" - required - /> -
- -
- - handleOIDCConfigChange('client_secret', e.target.value)} - placeholder="your-client-secret" - required - /> -
- -
- - handleOIDCConfigChange('authorization_url', e.target.value)} - placeholder="https://your-provider.com/application/o/authorize/" - required - /> -
- -
- - handleOIDCConfigChange('issuer_url', e.target.value)} - placeholder="https://your-provider.com/application/o/termix/" - required - /> -
- -
- - handleOIDCConfigChange('token_url', e.target.value)} - placeholder="https://your-provider.com/application/o/token/" - required - /> -
- -
- - handleOIDCConfigChange('identifier_path', e.target.value)} - placeholder="sub" - required - /> -

- JSON path to extract user ID from JWT (e.g., "sub", "email", - "preferred_username") -

-
- -
- - handleOIDCConfigChange('name_path', e.target.value)} - placeholder="name" - required - /> -

- JSON path to extract display name from JWT (e.g., "name", - "preferred_username") -

-
- -
- - handleOIDCConfigChange('scopes', e.target.value)} - placeholder="openid email profile" - required - /> -

- Space-separated list of OAuth2 scopes to request -

-
- -
- - -
- - {oidcSuccess && ( - - Success - {oidcSuccess} - - )} -
-
-
- - {/* Users Management Tab */} - -
-
-

User Management

- -
- - {usersLoading ? ( -
- Loading users... -
- ) : ( -
- - - - Username - Type - Actions - - - - {users.map((user) => ( - - - {user.username} - {user.is_admin && ( - - Admin - - )} - - - {user.is_oidc ? "External" : "Local"} - - - - - - ))} - -
-
- )} -
-
- - {/* Admins Management Tab */} - -
-

Admin Management

- - {/* Add New Admin Form */} -
-

Make User Admin

-
-
- -
- setNewAdminUsername(e.target.value)} - placeholder="Enter username to make admin" - required - /> - -
-
- - {makeAdminError && ( - - Error - {makeAdminError} - - )} - - {makeAdminSuccess && ( - - Success - {makeAdminSuccess} - - )} -
-
- - {/* Current Admins Table */} -
-

Current Admins

-
- - - - Username - Type - Actions - - - - {users.filter(user => user.is_admin).map((admin) => ( - - - {admin.username} - - Admin - - - - {admin.is_oidc ? "External" : "Local"} - - - - - - ))} - -
-
-
-
-
-
-
- - - - - - - -
-
- )} - - {/* Delete Account Confirmation Sheet */} - - - - Delete Account - - This action cannot be undone. This will permanently delete your account and all - associated data. - - -
- - Warning - - Deleting your account will remove all your data including SSH hosts, - configurations, and settings. - This action is irreversible. - - - - {deleteError && ( - - Error - {deleteError} - - )} - -
- {isAdmin && adminCount <= 1 && ( - - Cannot Delete Account - - You are the last admin user. You cannot delete your account as this - would leave the system without any administrators. - Please make another user an admin first, or contact system support. - - - )} - -
- - setDeletePassword(e.target.value)} - placeholder="Enter your password to confirm" - required - disabled={isAdmin && adminCount <= 1} - /> -
- -
- - -
-
-
-
-
-
- - {children} - -
-
- ) -} \ No newline at end of file diff --git a/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx b/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx deleted file mode 100644 index 571182f3..00000000 --- a/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx +++ /dev/null @@ -1,594 +0,0 @@ -import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, SidebarMenu, SidebarMenuItem, - SidebarProvider -} from '@/components/ui/sidebar.tsx'; -import {Separator} from '@/components/ui/separator.tsx'; -import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react'; -import {ScrollArea} from '@/components/ui/scroll-area.tsx'; -import {cn} from '@/lib/utils.ts'; -import {Input} from '@/components/ui/input.tsx'; -import {Button} from '@/components/ui/button.tsx'; -import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx'; -import { - getSSHHosts, - listSSHFiles, - connectSSH, - getSSHStatus, - getConfigEditorPinned, - addConfigEditorPinned, - removeConfigEditorPinned -} from '@/apps/SSH/ssh-axios.ts'; - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableConfigEditor: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} - -const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( - {onSelectView, onOpenFile, tabs, onHostChange}: { - onSelectView: (view: string) => void; - onOpenFile: (file: any) => void; - tabs: any[]; - onHostChange?: (host: SSHHost | null) => void; - }, - ref -) { - const [sshConnections, setSSHConnections] = useState([]); - const [loadingSSH, setLoadingSSH] = useState(false); - const [errorSSH, setErrorSSH] = useState(undefined); - const [view, setView] = useState<'servers' | 'files'>('servers'); - const [activeServer, setActiveServer] = useState(null); - const [currentPath, setCurrentPath] = useState('/'); - const [files, setFiles] = useState([]); - const pathInputRef = useRef(null); - - const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const [fileSearch, setFileSearch] = useState(''); - const [debouncedFileSearch, setDebouncedFileSearch] = useState(''); - useEffect(() => { - const handler = setTimeout(() => setDebouncedSearch(search), 200); - return () => clearTimeout(handler); - }, [search]); - useEffect(() => { - const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200); - return () => clearTimeout(handler); - }, [fileSearch]); - - const [sshSessionId, setSshSessionId] = useState(null); - const [filesLoading, setFilesLoading] = useState(false); - const [filesError, setFilesError] = useState(null); - const [connectingSSH, setConnectingSSH] = useState(false); - const [connectionCache, setConnectionCache] = useState>({}); - const [fetchingFiles, setFetchingFiles] = useState(false); - - useEffect(() => { - fetchSSH(); - }, []); - - async function fetchSSH() { - setLoadingSSH(true); - setErrorSSH(undefined); - try { - const hosts = await getSSHHosts(); - const configEditorHosts = hosts.filter(host => host.enableConfigEditor); - - if (configEditorHosts.length > 0) { - const firstHost = configEditorHosts[0]; - } - - setSSHConnections(configEditorHosts); - } catch (err: any) { - setErrorSSH('Failed to load SSH connections'); - } finally { - setLoadingSSH(false); - } - } - - async function connectToSSH(server: SSHHost): Promise { - const sessionId = server.id.toString(); - - const cached = connectionCache[sessionId]; - if (cached && Date.now() - cached.timestamp < 30000) { - setSshSessionId(cached.sessionId); - return cached.sessionId; - } - - if (connectingSSH) { - return null; - } - - setConnectingSSH(true); - - try { - if (!server.password && !server.key) { - setFilesError('No authentication credentials available for this SSH host'); - return null; - } - - const connectionConfig = { - ip: server.ip, - port: server.port, - username: server.username, - password: server.password, - sshKey: server.key, - keyPassword: server.keyPassword, - }; - - await connectSSH(sessionId, connectionConfig); - - setSshSessionId(sessionId); - - setConnectionCache(prev => ({ - ...prev, - [sessionId]: {sessionId, timestamp: Date.now()} - })); - - return sessionId; - } catch (err: any) { - setFilesError(err?.response?.data?.error || 'Failed to connect to SSH'); - setSshSessionId(null); - return null; - } finally { - setConnectingSSH(false); - } - } - - async function fetchFiles() { - if (fetchingFiles) { - return; - } - - setFetchingFiles(true); - setFiles([]); - setFilesLoading(true); - setFilesError(null); - - try { - let pinnedFiles: any[] = []; - try { - if (activeServer) { - pinnedFiles = await getConfigEditorPinned(activeServer.id); - } - } catch (err) { - } - - if (activeServer && sshSessionId) { - let res: any[] = []; - - try { - const status = await getSSHStatus(sshSessionId); - if (!status.connected) { - const newSessionId = await connectToSSH(activeServer); - if (newSessionId) { - setSshSessionId(newSessionId); - res = await listSSHFiles(newSessionId, currentPath); - } else { - throw new Error('Failed to reconnect SSH session'); - } - } else { - res = await listSSHFiles(sshSessionId, currentPath); - } - } catch (sessionErr) { - const newSessionId = await connectToSSH(activeServer); - if (newSessionId) { - setSshSessionId(newSessionId); - res = await listSSHFiles(newSessionId, currentPath); - } else { - throw sessionErr; - } - } - - const processedFiles = (res || []).map((f: any) => { - const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name; - const isPinned = pinnedFiles.some(pinned => pinned.path === filePath); - return { - ...f, - path: filePath, - isPinned, - isSSH: true, - sshSessionId: sshSessionId - }; - }); - - setFiles(processedFiles); - } - } catch (err: any) { - setFiles([]); - setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files'); - } finally { - setFilesLoading(false); - setFetchingFiles(false); - } - } - - useEffect(() => { - if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) { - const timeoutId = setTimeout(() => { - fetchFiles(); - }, 100); - return () => clearTimeout(timeoutId); - } - }, [currentPath, view, activeServer, sshSessionId]); - - async function handleSelectServer(server: SSHHost) { - if (connectingSSH) { - return; - } - - setFetchingFiles(false); - setFilesLoading(false); - setFilesError(null); - setFiles([]); - - setActiveServer(server); - setCurrentPath(server.defaultPath || '/'); - setView('files'); - - const sessionId = await connectToSSH(server); - if (sessionId) { - setSshSessionId(sessionId); - if (onHostChange) { - onHostChange(server); - } - } else { - w - setView('servers'); - setActiveServer(null); - } - } - - useImperativeHandle(ref, () => ({ - openFolder: async (server: SSHHost, path: string) => { - if (connectingSSH || fetchingFiles) { - return; - } - - if (activeServer?.id === server.id && currentPath === path) { - setTimeout(() => fetchFiles(), 100); - return; - } - - setFetchingFiles(false); - setFilesLoading(false); - setFilesError(null); - setFiles([]); - - setActiveServer(server); - setCurrentPath(path); - setView('files'); - - if (!sshSessionId || activeServer?.id !== server.id) { - const sessionId = await connectToSSH(server); - if (sessionId) { - setSshSessionId(sessionId); - if (onHostChange && activeServer?.id !== server.id) { - onHostChange(server); - } - } else { - setView('servers'); - setActiveServer(null); - } - } else { - if (onHostChange && activeServer?.id !== server.id) { - onHostChange(server); - } - } - }, - fetchFiles: () => { - if (activeServer && sshSessionId) { - fetchFiles(); - } - } - })); - - useEffect(() => { - if (pathInputRef.current) { - pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth; - } - }, [currentPath]); - - const sshByFolder: Record = {}; - sshConnections.forEach(conn => { - const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder'; - if (!sshByFolder[folder]) sshByFolder[folder] = []; - sshByFolder[folder].push(conn); - }); - - const sortedFolders = Object.keys(sshByFolder); - if (sortedFolders.includes('No Folder')) { - sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1); - sortedFolders.unshift('No Folder'); - } - - const filteredSshByFolder: Record = {}; - Object.entries(sshByFolder).forEach(([folder, hosts]) => { - filteredSshByFolder[folder] = hosts.filter(conn => { - const q = debouncedSearch.trim().toLowerCase(); - if (!q) return true; - return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) || - (conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) || - (conn.tags || []).join(' ').toLowerCase().includes(q); - }); - }); - - const filteredFiles = files.filter(file => { - const q = debouncedFileSearch.trim().toLowerCase(); - if (!q) return true; - return file.name.toLowerCase().includes(q); - }); - - return ( - - - - - - Termix / Config - - - - - - - - - -
- {view === 'servers' && ( - <> -
- setSearch(e.target.value)} - placeholder="Search hosts by name, username, IP, folder, tags..." - className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded" - autoComplete="off" - /> -
- -
-
-
- -
-
-
- - {sortedFolders.map((folder, idx) => ( - - - {folder} - - {filteredSshByFolder[folder].map(conn => ( - - ))} - - - {idx < sortedFolders.length - 1 && ( -
- -
- )} -
- ))} -
-
-
-
-
-
- - )} - {view === 'files' && activeServer && ( -
-
- - setCurrentPath(e.target.value)} - className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]" - /> -
-
- setFileSearch(e.target.value)} - /> -
-
- -
- {connectingSSH || filesLoading ? ( -
Loading...
- ) : filesError ? ( -
{filesError}
- ) : filteredFiles.length === 0 ? ( -
No files or - folders found.
- ) : ( -
- {filteredFiles.map((item: any) => { - const isOpen = (tabs || []).some((t: any) => t.id === item.path); - return ( -
-
!isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({ - name: item.name, - path: item.path, - isSSH: item.isSSH, - sshSessionId: item.sshSessionId - }))} - > - {item.type === 'directory' ? - : - } - {item.name} -
-
- {item.type === 'file' && ( - - )} -
-
- ); - })} -
- )} -
-
-
-
- )} -
-
-
-
-
-
- ); -}); -export {ConfigEditorSidebar}; \ No newline at end of file diff --git a/src/apps/SSH/Config Editor/ConfigTabList.tsx b/src/apps/SSH/Config Editor/ConfigTabList.tsx deleted file mode 100644 index 37ae5962..00000000 --- a/src/apps/SSH/Config Editor/ConfigTabList.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; -import {Button} from '@/components/ui/button.tsx'; -import {X, Home} from 'lucide-react'; - -interface ConfigTab { - id: string | number; - title: string; -} - -interface ConfigTabListProps { - tabs: ConfigTab[]; - activeTab: string | number; - setActiveTab: (tab: string | number) => void; - closeTab: (tab: string | number) => void; - onHomeClick: () => void; -} - -export function ConfigTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: ConfigTabListProps) { - return ( -
- - {tabs.map((tab, index) => { - const isActive = tab.id === activeTab; - return ( -
-
- - - -
-
- ); - })} -
- ); -} \ No newline at end of file diff --git a/src/apps/SSH/Config Editor/ConfigTopbar.tsx b/src/apps/SSH/Config Editor/ConfigTopbar.tsx deleted file mode 100644 index 62637cde..00000000 --- a/src/apps/SSH/Config Editor/ConfigTopbar.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; -import { ConfigTabList } from "./ConfigTabList.tsx"; - -export function ConfigTopbar(props: any): React.ReactElement { - return ( - - ) -} \ No newline at end of file diff --git a/src/apps/SSH/Manager/SSHManagerSidebar.tsx b/src/apps/SSH/Manager/SSHManagerSidebar.tsx deleted file mode 100644 index 819830f3..00000000 --- a/src/apps/SSH/Manager/SSHManagerSidebar.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; - -import { - CornerDownLeft -} from "lucide-react" - -import { - Button -} from "@/components/ui/button.tsx" - -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuItem, SidebarProvider, -} from "@/components/ui/sidebar.tsx" - -import { - Separator, -} from "@/components/ui/separator.tsx" - -interface SidebarProps { - onSelectView: (view: string) => void; -} - -export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement { - return ( - - - - - - Termix / SSH Manager - - - - - - {/* Sidebar Items */} - - - - - - - - - - - - ) -} \ No newline at end of file diff --git a/src/apps/SSH/Terminal/Terminal.tsx b/src/apps/SSH/Terminal/Terminal.tsx deleted file mode 100644 index e6e92600..00000000 --- a/src/apps/SSH/Terminal/Terminal.tsx +++ /dev/null @@ -1,784 +0,0 @@ -import React, {useState, useRef, useEffect} from "react"; -import {TerminalSidebar} from "@/apps/SSH/Terminal/TerminalSidebar.tsx"; -import {TerminalComponent} from "./TerminalComponent.tsx"; -import {TerminalTopbar} from "@/apps/SSH/Terminal/TerminalTopbar.tsx"; -import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx'; -import * as ResizablePrimitive from "react-resizable-panels"; -import {ChevronDown, ChevronRight} from "lucide-react"; - -interface ConfigEditorProps { - onSelectView: (view: string) => void; -} - -type Tab = { - id: number; - title: string; - hostConfig: any; - terminalRef: React.RefObject; -}; - -export function Terminal({onSelectView}: ConfigEditorProps): React.ReactElement { - const [allTabs, setAllTabs] = useState([]); - const [currentTab, setCurrentTab] = useState(null); - const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); - const nextTabId = useRef(1); - - const [isSidebarOpen, setIsSidebarOpen] = useState(true); - const [isTopbarOpen, setIsTopbarOpen] = useState(true); - const SIDEBAR_WIDTH = 256; - const HANDLE_THICKNESS = 10; - - const [panelRects, setPanelRects] = useState>({}); - const panelRefs = useRef>({}); - const panelGroupRefs = useRef<{ [key: string]: any }>({}); - - const setActiveTab = (tabId: number) => { - setCurrentTab(tabId); - }; - - const fitVisibleTerminals = () => { - allTabs.forEach((terminal) => { - const isVisible = - (allSplitScreenTab.length === 0 && terminal.id === currentTab) || - (allSplitScreenTab.length > 0 && (terminal.id === currentTab || allSplitScreenTab.includes(terminal.id))); - if (isVisible && terminal.terminalRef && terminal.terminalRef.current && typeof terminal.terminalRef.current.fit === 'function') { - terminal.terminalRef.current.fit(); - } - }); - }; - - const setSplitScreenTab = (tabId: number) => { - fitVisibleTerminals(); - setAllSplitScreenTab((prev) => { - let next; - if (prev.includes(tabId)) { - next = prev.filter((id) => id !== tabId); - } else if (prev.length < 3) { - next = [...prev, tabId]; - } else { - next = prev; - } - setTimeout(() => fitVisibleTerminals(), 0); - return next; - }); - }; - - const setCloseTab = (tabId: number) => { - const tab = allTabs.find((t) => t.id === tabId); - if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") { - tab.terminalRef.current.disconnect(); - } - setAllTabs((prev) => prev.filter((tab) => tab.id !== tabId)); - setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId)); - if (currentTab === tabId) { - const remainingTabs = allTabs.filter((tab) => tab.id !== tabId); - setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : null); - } - }; - - const updatePanelRects = () => { - setPanelRects((prev) => { - const next: Record = {...prev}; - Object.entries(panelRefs.current).forEach(([id, ref]) => { - if (ref) { - next[id] = ref.getBoundingClientRect(); - } - }); - return next; - }); - }; - - useEffect(() => { - const observers: ResizeObserver[] = []; - Object.entries(panelRefs.current).forEach(([id, ref]) => { - if (ref) { - const observer = new ResizeObserver(() => updatePanelRects()); - observer.observe(ref); - observers.push(observer); - } - }); - updatePanelRects(); - return () => { - observers.forEach((observer) => observer.disconnect()); - }; - }, [allSplitScreenTab, currentTab, allTabs.length]); - - const renderAllTerminals = () => { - const layoutStyles: Record = {}; - const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id)); - const mainTab = allTabs.find((tab) => tab.id === currentTab); - const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t); - if (allSplitScreenTab.length === 0 && mainTab) { - layoutStyles[mainTab.id] = { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - zIndex: 20, - display: 'block', - pointerEvents: 'auto', - }; - } else { - layoutTabs.forEach((tab) => { - const rect = panelRects[String(tab.id)]; - if (rect) { - const parentRect = panelRefs.current['parent']?.getBoundingClientRect(); - let top = rect.top, left = rect.left, width = rect.width, height = rect.height; - if (parentRect) { - top = rect.top - parentRect.top; - left = rect.left - parentRect.left; - } - layoutStyles[tab.id] = { - position: 'absolute', - top: top + 28, - left, - width, - height: height - 28, - zIndex: 20, - display: 'block', - pointerEvents: 'auto', - }; - } - }); - } - return ( -
{ - panelRefs.current['parent'] = el; - }} style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - zIndex: 1, - overflow: 'hidden' - }}> - {allTabs.map((tab) => { - const style = layoutStyles[tab.id] - ? {...layoutStyles[tab.id], overflow: 'hidden'} - : {display: 'none', overflow: 'hidden'}; - const isVisible = !!layoutStyles[tab.id]; - return ( -
- 0} - /> -
- ); - })} -
- ); - }; - - const renderSplitOverlays = () => { - const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id)); - const mainTab = allTabs.find((tab) => tab.id === currentTab); - const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t); - if (allSplitScreenTab.length === 0) return null; - - if (layoutTabs.length === 2) { - const [tab1, tab2] = layoutTabs; - return ( -
- { - panelGroupRefs.current['main'] = el; - }} - direction="horizontal" - className="h-full w-full" - id="main-horizontal" - > - -
{ - panelRefs.current[String(tab1.id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{tab1.title}
-
-
- - -
{ - panelRefs.current[String(tab2.id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{tab2.title}
-
-
-
-
- ); - } - if (layoutTabs.length === 3) { - return ( -
- { - panelGroupRefs.current['main'] = el; - }} - direction="vertical" - className="h-full w-full" - id="main-vertical" - > - - { - panelGroupRefs.current['top'] = el; - }} direction="horizontal" className="h-full w-full" id="top-horizontal"> - -
{ - panelRefs.current[String(layoutTabs[0].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[0].title}
-
-
- - -
{ - panelRefs.current[String(layoutTabs[1].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[1].title}
-
-
-
-
- - -
{ - panelRefs.current[String(layoutTabs[2].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[2].title}
-
-
-
-
- ); - } - if (layoutTabs.length === 4) { - return ( -
- { - panelGroupRefs.current['main'] = el; - }} - direction="vertical" - className="h-full w-full" - id="main-vertical" - > - - { - panelGroupRefs.current['top'] = el; - }} direction="horizontal" className="h-full w-full" id="top-horizontal"> - -
{ - panelRefs.current[String(layoutTabs[0].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[0].title}
-
-
- - -
{ - panelRefs.current[String(layoutTabs[1].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[1].title}
-
-
-
-
- - - { - panelGroupRefs.current['bottom'] = el; - }} direction="horizontal" className="h-full w-full" id="bottom-horizontal"> - -
{ - panelRefs.current[String(layoutTabs[2].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[2].title}
-
-
- - -
{ - panelRefs.current[String(layoutTabs[3].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[3].title}
-
-
-
-
-
-
- ); - } - return null; - }; - - const onAddHostSubmit = (data: any) => { - const id = nextTabId.current++; - const title = `${data.ip || "Host"}:${data.port || 22}`; - const terminalRef = React.createRef(); - const newTab: Tab = { - id, - title, - hostConfig: data, - terminalRef, - }; - setAllTabs((prev) => [...prev, newTab]); - setCurrentTab(id); - setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id)); - }; - - const getUniqueTabTitle = (baseTitle: string) => { - let title = baseTitle; - let count = 1; - const existingTitles = allTabs.map(t => t.title); - while (existingTitles.includes(title)) { - title = `${baseTitle} (${count})`; - count++; - } - return title; - }; - - const onHostConnect = (hostConfig: any) => { - const baseTitle = hostConfig.name?.trim() ? hostConfig.name : `${hostConfig.ip || "Host"}:${hostConfig.port || 22}`; - const title = getUniqueTabTitle(baseTitle); - const terminalRef = React.createRef(); - const id = nextTabId.current++; - const newTab: Tab = { - id, - title, - hostConfig, - terminalRef, - }; - setAllTabs((prev) => [...prev, newTab]); - setCurrentTab(id); - setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id)); - }; - - return ( -
-
- { - allTabs.forEach(tab => { - if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) { - tab.terminalRef.current.sendInput(command); - } - }); - }} - onCloseSidebar={() => setIsSidebarOpen(false)} - open={isSidebarOpen} - onOpenChange={setIsSidebarOpen} - /> -
- -
-
- setIsTopbarOpen(false)} - /> -
- {!isTopbarOpen && ( -
setIsTopbarOpen(true)} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: HANDLE_THICKNESS, - background: '#222224', - cursor: 'pointer', - zIndex: 12, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }} - title="Show top bar"> - -
- )} - -
- {allTabs.length === 0 && ( -
-
- Welcome to Termix SSH -
-
- Click on any host title in the sidebar to open a terminal connection, or use the "Add - Host" button to create a new connection. -
-
- )} - {allSplitScreenTab.length > 0 && ( -
- -
- )} - {renderAllTerminals()} - {renderSplitOverlays()} -
-
- - {!isSidebarOpen && ( -
setIsSidebarOpen(true)} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: HANDLE_THICKNESS, - height: '100%', - background: '#222224', - cursor: 'pointer', - zIndex: 20, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }} - title="Show sidebar"> - -
- )} -
- ); -} \ No newline at end of file diff --git a/src/apps/SSH/Terminal/TerminalSidebar.tsx b/src/apps/SSH/Terminal/TerminalSidebar.tsx deleted file mode 100644 index 90318520..00000000 --- a/src/apps/SSH/Terminal/TerminalSidebar.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import React, {useState} from 'react'; - -import { - CornerDownLeft, - Hammer, Pin, Menu -} from "lucide-react" - -import { - Button -} from "@/components/ui/button.tsx" - -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuItem, SidebarProvider, -} from "@/components/ui/sidebar.tsx" - -import { - Separator, -} from "@/components/ui/separator.tsx" -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger -} from "@/components/ui/sheet.tsx"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion.tsx"; -import {ScrollArea} from "@/components/ui/scroll-area.tsx"; -import {Input} from "@/components/ui/input.tsx"; -import {getSSHHosts} from "@/apps/SSH/ssh-axios"; -import {Checkbox} from "@/components/ui/checkbox.tsx"; - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableConfigEditor: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} - -export interface SidebarProps { - onSelectView: (view: string) => void; - onHostConnect: (hostConfig: any) => void; - allTabs: { id: number; title: string; terminalRef: React.RefObject }[]; - runCommandOnTabs: (tabIds: number[], command: string) => void; - onCloseSidebar?: () => void; - onAddHostSubmit?: (data: any) => void; - open?: boolean; - onOpenChange?: (open: boolean) => void; -} - -export function TerminalSidebar({ - onSelectView, - onHostConnect, - allTabs, - runCommandOnTabs, - onCloseSidebar, - open, - onOpenChange - }: SidebarProps): React.ReactElement { - const [hosts, setHosts] = useState([]); - const [hostsLoading, setHostsLoading] = useState(false); - const [hostsError, setHostsError] = useState(null); - const prevHostsRef = React.useRef([]); - - const fetchHosts = React.useCallback(async () => { - setHostsLoading(true); - setHostsError(null); - try { - const newHosts = await getSSHHosts(); - const terminalHosts = newHosts.filter(host => host.enableTerminal); - - const prevHosts = prevHostsRef.current; - const isSame = - terminalHosts.length === prevHosts.length && - terminalHosts.every((h: SSHHost, i: number) => { - const prev = prevHosts[i]; - if (!prev) return false; - return ( - h.id === prev.id && - h.name === prev.name && - h.folder === prev.folder && - h.ip === prev.ip && - h.port === prev.port && - h.username === prev.username && - h.password === prev.password && - h.authType === prev.authType && - h.key === prev.key && - h.pin === prev.pin && - JSON.stringify(h.tags) === JSON.stringify(prev.tags) - ); - }); - if (!isSame) { - setHosts(terminalHosts); - prevHostsRef.current = terminalHosts; - } - } catch (err: any) { - setHostsError('Failed to load hosts'); - } finally { - setHostsLoading(false); - } - }, []); - - React.useEffect(() => { - fetchHosts(); - const interval = setInterval(fetchHosts, 10000); - return () => clearInterval(interval); - }, [fetchHosts]); - - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - React.useEffect(() => { - const handler = setTimeout(() => setDebouncedSearch(search), 200); - return () => clearTimeout(handler); - }, [search]); - - const filteredHosts = React.useMemo(() => { - if (!debouncedSearch.trim()) return hosts; - const q = debouncedSearch.trim().toLowerCase(); - return hosts.filter(h => { - const searchableText = [ - h.name || '', - h.username, - h.ip, - h.folder || '', - ...(h.tags || []), - h.authType, - h.defaultPath || '' - ].join(' ').toLowerCase(); - return searchableText.includes(q); - }); - }, [hosts, debouncedSearch]); - - const hostsByFolder = React.useMemo(() => { - const map: Record = {}; - filteredHosts.forEach(h => { - const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder'; - if (!map[folder]) map[folder] = []; - map[folder].push(h); - }); - return map; - }, [filteredHosts]); - - const sortedFolders = React.useMemo(() => { - const folders = Object.keys(hostsByFolder); - folders.sort((a, b) => { - if (a === 'No Folder') return -1; - if (b === 'No Folder') return 1; - return a.localeCompare(b); - }); - return folders; - }, [hostsByFolder]); - - const getSortedHosts = (arr: SSHHost[]) => { - const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); - const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); - return [...pinned, ...rest]; - }; - - const [toolsSheetOpen, setToolsSheetOpen] = useState(false); - const [toolsCommand, setToolsCommand] = useState(""); - const [selectedTabIds, setSelectedTabIds] = useState([]); - - const handleTabToggle = (tabId: number) => { - setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]); - }; - - const handleRunCommand = () => { - if (selectedTabIds.length && toolsCommand.trim()) { - let cmd = toolsCommand; - if (!cmd.endsWith("\n")) cmd += "\n"; - runCommandOnTabs(selectedTabIds, cmd); - setToolsCommand(""); - } - }; - - function getCookie(name: string) { - return document.cookie.split('; ').reduce((r, v) => { - const parts = v.split('='); - return parts[0] === name ? decodeURIComponent(parts[1]) : r; - }, ""); - } - - const updateRightClickCopyPaste = (checked) => { - document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`; - } - - return ( - - - - - - Termix / Terminal - - - - - - - - - - - - -
-
- setSearch(e.target.value)} - placeholder="Search hosts by name, username, IP, folder, tags..." - className="w-full h-8 text-sm bg-background border border-border rounded" - autoComplete="off" - /> -
-
- -
- {hostsError && ( -
-
{hostsError}
-
- )} -
- - 0 ? sortedFolders : undefined}> - {sortedFolders.map((folder, idx) => ( - - - {folder} - - {getSortedHosts(hostsByFolder[folder]).map(host => ( -
- -
- ))} -
-
- {idx < sortedFolders.length - 1 && ( -
- -
- )} -
- ))} -
-
-
-
-
-
-
-
- - - - - - - Tools - -
- - - Run multiwindow - commands - -