diff --git a/.env b/.env index 3967bc09..fbd02ad8 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=1.1 \ No newline at end of file +VERSION=1.3.1 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..89762628 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + groups: + dev-patch-updates: + dependency-type: "development" + update-types: + - "patch" + dev-minor-updates: + dependency-type: "development" + update-types: + - "minor" + prod-patch-updates: + dependency-type: "production" + update-types: + - "patch" + prod-minor-updates: + dependency-type: "production" + update-types: + - "minor" + + - package-ecosystem: "docker" + directory: "/docker" + schedule: + interval: "daily" + groups: + patch-updates: + update-types: + - "patch" + minor-updates: + update-types: + - "minor" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 2d473e10..4a7cecd9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 @@ -37,7 +37,7 @@ jobs: network=host - name: Cache npm dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.npm @@ -48,7 +48,7 @@ jobs: ${{ runner.os }}-node- - name: Cache Docker layers - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }} @@ -78,7 +78,7 @@ jobs: echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV - name: Build and Push Multi-Arch Docker Image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 64243c8f..b36e2762 100644 --- a/README.md +++ b/README.md @@ -13,33 +13,39 @@ [![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 Banner +

+ +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** - Ability to manage admins, and give more fine-grained control over their permissions, share hosts, reset passwords, delete accounts, etc +- **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 # Installation Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here: @@ -78,7 +84,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 463d8ed9..15f3d81f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Install dependencies and build frontend -FROM node:18-alpine AS deps +FROM node:24-alpine AS deps WORKDIR /app RUN apk add --no-cache python3 make g++ @@ -26,7 +26,7 @@ COPY . . RUN npm run build:backend # Stage 4: Production dependencies -FROM node:18-alpine AS production-deps +FROM node:24-alpine AS production-deps WORKDIR /app COPY package*.json ./ @@ -35,7 +35,7 @@ RUN npm ci --only=production --ignore-scripts --force && \ npm cache clean --force # Stage 5: Build native modules -FROM node:18-alpine AS native-builder +FROM node:24-alpine AS native-builder WORKDIR /app RUN apk add --no-cache python3 make g++ @@ -46,7 +46,7 @@ RUN npm ci --only=production bcryptjs better-sqlite3 --force && \ npm cache clean --force # Stage 6: Final image -FROM node:18-alpine +FROM node:24-alpine ENV DATA_DIR=/app/data \ PORT=8080 \ NODE_ENV=production @@ -72,8 +72,8 @@ 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 -CMD ["/entrypoint.sh"] \ No newline at end of file +CMD ["/entrypoint.sh"] diff --git a/docker/nginx.conf b/docker/nginx.conf index 909aa46e..728aad3b 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -45,7 +45,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - location /ssh/db/ { + location /alerts/ { + 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/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_set_header Host $host; @@ -62,6 +71,14 @@ http { proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_connect_timeout 75s; + proxy_set_header Connection ""; + + proxy_buffering off; + proxy_request_buffering off; + 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; @@ -76,7 +93,34 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - location /ssh/config_editor/ { + 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; + } + + location /ssh/file_manager/ssh/ { proxy_pass http://127.0.0.1:8084; proxy_http_version 1.1; proxy_set_header Host $host; @@ -85,8 +129,17 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - location ~ ^/ssh/config_editor/(recent|pinned|shortcuts) { - proxy_pass http://127.0.0.1:8081; + 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; diff --git a/package-lock.json b/package-lock.json index 262b5166..23dc8262 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", @@ -69,27 +72,27 @@ "zod": "^4.0.5" }, "devDependencies": { - "@eslint/js": "^9.30.1", + "@eslint/js": "^9.34.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^24.0.13", + "@types/node": "^24.3.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", "@vitejs/plugin-react-swc": "^3.10.2", "autoprefixer": "^10.4.21", - "eslint": "^9.30.1", + "eslint": "^9.34.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", "ts-node": "^10.9.2", "tw-animate-css": "^1.3.5", - "typescript": "~5.8.3", - "typescript-eslint": "^8.35.1", - "vite": "^7.0.4" + "typescript": "~5.9.2", + "typescript-eslint": "^8.40.0", + "vite": "^7.1.3" } }, "node_modules/@ampproject/remapping": { @@ -1020,9 +1023,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1030,9 +1033,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1080,9 +1083,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", - "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", "dev": true, "license": "MIT", "engines": { @@ -1103,13 +1106,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -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", @@ -3337,6 +3525,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -3532,12 +3774,12 @@ } }, "node_modules/@types/node": { - "version": "24.0.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz", - "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~7.10.0" } }, "node_modules/@types/qs": { @@ -3631,17 +3873,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz", - "integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", + "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.37.0", - "@typescript-eslint/type-utils": "8.37.0", - "@typescript-eslint/utils": "8.37.0", - "@typescript-eslint/visitor-keys": "8.37.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/type-utils": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -3655,9 +3897,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.37.0", + "@typescript-eslint/parser": "^8.40.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -3671,16 +3913,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz", - "integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz", + "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.37.0", - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/typescript-estree": "8.37.0", - "@typescript-eslint/visitor-keys": "8.37.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", "debug": "^4.3.4" }, "engines": { @@ -3692,18 +3934,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz", - "integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", + "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.37.0", - "@typescript-eslint/types": "^8.37.0", + "@typescript-eslint/tsconfig-utils": "^8.40.0", + "@typescript-eslint/types": "^8.40.0", "debug": "^4.3.4" }, "engines": { @@ -3714,18 +3956,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz", - "integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz", + "integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/visitor-keys": "8.37.0" + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3736,9 +3978,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz", - "integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", + "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", "dev": true, "license": "MIT", "engines": { @@ -3749,19 +3991,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz", - "integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz", + "integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/typescript-estree": "8.37.0", - "@typescript-eslint/utils": "8.37.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -3774,13 +4016,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz", - "integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", + "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", "dev": true, "license": "MIT", "engines": { @@ -3792,16 +4034,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz", - "integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", + "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.37.0", - "@typescript-eslint/tsconfig-utils": "8.37.0", - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/visitor-keys": "8.37.0", + "@typescript-eslint/project-service": "8.40.0", + "@typescript-eslint/tsconfig-utils": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -3817,7 +4059,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -3847,16 +4089,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz", - "integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", + "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.37.0", - "@typescript-eslint/types": "8.37.0", - "@typescript-eslint/typescript-estree": "8.37.0" + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3867,17 +4109,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz", - "integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", + "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/types": "8.40.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -5214,20 +5456,20 @@ } }, "node_modules/eslint": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", - "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.31.0", - "@eslint/plugin-kit": "^0.3.1", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -6784,6 +7026,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 +7943,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", @@ -8039,9 +8301,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8053,16 +8315,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.37.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz", - "integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.40.0.tgz", + "integrity": "sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.37.0", - "@typescript-eslint/parser": "8.37.0", - "@typescript-eslint/typescript-estree": "8.37.0", - "@typescript-eslint/utils": "8.37.0" + "@typescript-eslint/eslint-plugin": "8.40.0", + "@typescript-eslint/parser": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8073,13 +8335,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "license": "MIT" }, "node_modules/unpipe": { @@ -8216,16 +8478,16 @@ } }, "node_modules/vite": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz", - "integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", + "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.6", - "picomatch": "^4.0.2", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rollup": "^4.40.0", + "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "bin": { @@ -8290,10 +8552,13 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -8304,9 +8569,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 16eabebe..b016dcb0 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", @@ -73,26 +76,26 @@ "zod": "^4.0.5" }, "devDependencies": { - "@eslint/js": "^9.30.1", + "@eslint/js": "^9.34.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^24.0.13", + "@types/node": "^24.3.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", "@vitejs/plugin-react-swc": "^3.10.2", "autoprefixer": "^10.4.21", - "eslint": "^9.30.1", + "eslint": "^9.34.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", "ts-node": "^10.9.2", "tw-animate-css": "^1.3.5", - "typescript": "~5.8.3", - "typescript-eslint": "^8.35.1", - "vite": "^7.0.4" + "typescript": "~5.9.2", + "typescript-eslint": "^8.40.0", + "vite": "^7.1.3" } } 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 8940ef8c..fb3e1525 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,69 @@ -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 {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx"; +import { AdminSettings } from "@/ui/Admin/AdminSettings"; +import { Toaster } from "@/components/ui/sonner"; +import { getUserInfo } from "@/ui/main-axios.ts"; -import {Homepage} from "@/apps/Homepage/Homepage.tsx" -import {SSH} from "@/apps/SSH/Terminal/SSH.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" +function getCookie(name: string) { + return document.cookie.split('; ').reduce((r, v) => { + const parts = v.split('='); + return parts[0] === name ? decodeURIComponent(parts[1]) : r; + }, ""); +} -function App() { - const [view, setView] = React.useState("homepage") - const [mountedViews, setMountedViews] = React.useState>(new Set(["homepage"])) +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); + getUserInfo() + .then((meRes) => { + setIsAuthenticated(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + }) + .catch((err) => { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + }) + .finally(() => setAuthLoading(false)); + } else { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + setAuthLoading(false); + } + } + + checkAuth() + + const handleStorageChange = () => checkAuth() + window.addEventListener('storage', handleStorageChange) + + return () => window.removeEventListener('storage', handleStorageChange) + }, []) const handleSelectView = (nextView: string) => { setMountedViews((prev) => { @@ -20,37 +75,138 @@ 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 && ( +
+
+
+ )} + + {!isAuthenticated && !authLoading && ( +
+ +
+ )} + + {isAuthenticated && ( + +
+
- )} - {mountedViews.has("ssh_manager") && ( -
- + +
+
- )} - {mountedViews.has("terminal") && ( -
- + +
+
- )} - {mountedViews.has("tunnel") && ( -
- + +
+
- )} - {mountedViews.has("config_editor") && ( -
- -
- )} -
+ + + + )} +
) } +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 470168b5..00000000 --- a/src/apps/Homepage/Homepage.tsx +++ /dev/null @@ -1,92 +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"; - -interface HomepageProps { - onSelectView: (view: string) => void; -} - -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, -}); - -export function Homepage({onSelectView}: HomepageProps): React.ReactElement { - const [loggedIn, setLoggedIn] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [username, setUsername] = 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); - setDbError(null); - }) - .catch((err) => { - setLoggedIn(false); - setIsAdmin(false); - setUsername(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 ( - -
-
- - -
-
-
- ); -} \ 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 3ad1b24a..00000000 --- a/src/apps/Homepage/HomepageAuth.tsx +++ /dev/null @@ -1,400 +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; - loggedIn: boolean; - authLoading: boolean; - dbError: string | null; - setDbError: (error: string | null) => void; -} - -export function HomepageAuth({ - className, - setLoggedIn, - setIsAdmin, - setUsername, - loggedIn, - authLoading, - dbError, - setDbError, - ...props - }: HomepageAuthProps) { - const [tab, setTab] = useState<"login" | "signup" | "external">("login"); - const [localUsername, setLocalUsername] = useState(""); - const [password, setPassword] = 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); - - 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); - try { - let res, meRes; - if (tab === "login") { - res = await API.post("/login", {username: localUsername, password}); - } else { - 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); - setDbError(null); - } catch (err: any) { - setError(err?.response?.data?.error || "Unknown error"); - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(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 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); - 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); - 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" : - "Login with external provider"} -

-
- - {tab === "external" ? ( -
-
-

Login using your configured external identity provider

-
- -
- ) : ( -
-
- - setLocalUsername(e.target.value)} - disabled={loading || internalLoggedIn} - /> -
-
- - setPassword(e.target.value)} - disabled={loading || internalLoggedIn}/> -
- -
- )} - - )} - {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 ec2af144..00000000 --- a/src/apps/Homepage/HomepageSidebar.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import React from 'react'; -import { - Computer, - Server, - File, - Hammer, ChevronUp, User2, HardDrive -} 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 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); - - React.useEffect(() => { - if (adminSheetOpen) { - API.get("/registration-allowed").then(res => { - setAllowRegistration(res.data.allowed); - }); - - API.get("/oidc-config").then(res => { - if (res.data) { - setOidcConfig(res.data); - } - }).catch((error) => { - }); - } - }, [adminSheetOpen]); - - const handleToggle = async (checked: boolean) => { - 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(); - 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 - })); - }; - - 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 && ( - setAdminSheetOpen(true)}> - Admin Settings - - )} - - Sign out - - - - - - - {/* Admin Settings Sheet (always rendered, only openable if isAdmin) */} - {isAdmin && ( - - - - Admin Settings - -
- {/* Registration Settings */} -
-

User Registration

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

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="http://100.98.3.50:9000/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} - - )} -
-
-
- - - - - - -
-
- )} -
- - {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/SSHManagerHostViewer.tsx b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx deleted file mode 100644 index 7fa5e714..00000000 --- a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import React, {useState, useEffect, useMemo} from "react"; -import {Card, CardContent} from "@/components/ui/card"; -import {Button} from "@/components/ui/button"; -import {Badge} from "@/components/ui/badge"; -import {ScrollArea} from "@/components/ui/scroll-area"; -import {Input} from "@/components/ui/input"; -import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; -import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios"; -import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react"; - -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableConfigEditor: boolean; - defaultPath: string; - tunnelConnections: any[]; - createdAt: string; - updatedAt: string; -} - -interface SSHManagerHostViewerProps { - onEditHost?: (host: SSHHost) => void; -} - -export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { - const [hosts, setHosts] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - - useEffect(() => { - fetchHosts(); - }, []); - - const fetchHosts = async () => { - try { - setLoading(true); - const data = await getSSHHosts(); - setHosts(data); - setError(null); - } catch (err) { - setError('Failed to load hosts'); - } finally { - setLoading(false); - } - }; - - const handleDelete = async (hostId: number, hostName: string) => { - if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) { - try { - await deleteSSHHost(hostId); - await fetchHosts(); - } catch (err) { - alert('Failed to delete host'); - } - } - }; - - const handleEdit = (host: SSHHost) => { - if (onEditHost) { - onEditHost(host); - } - }; - - const filteredAndSortedHosts = useMemo(() => { - let filtered = hosts; - - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - filtered = hosts.filter(host => { - const searchableText = [ - host.name || '', - host.username, - host.ip, - host.folder || '', - ...(host.tags || []), - host.authType, - host.defaultPath || '' - ].join(' ').toLowerCase(); - return searchableText.includes(query); - }); - } - - return filtered.sort((a, b) => { - if (a.pin && !b.pin) return -1; - if (!a.pin && b.pin) return 1; - - const aName = a.name || a.username; - const bName = b.name || b.username; - return aName.localeCompare(bName); - }); - }, [hosts, searchQuery]); - - const hostsByFolder = useMemo(() => { - const grouped: { [key: string]: SSHHost[] } = {}; - - filteredAndSortedHosts.forEach(host => { - const folder = host.folder || 'Uncategorized'; - if (!grouped[folder]) { - grouped[folder] = []; - } - grouped[folder].push(host); - }); - - const sortedFolders = Object.keys(grouped).sort((a, b) => { - if (a === 'Uncategorized') return -1; - if (b === 'Uncategorized') return 1; - return a.localeCompare(b); - }); - - const sortedGrouped: { [key: string]: SSHHost[] } = {}; - sortedFolders.forEach(folder => { - sortedGrouped[folder] = grouped[folder]; - }); - - return sortedGrouped; - }, [filteredAndSortedHosts]); - - if (loading) { - return ( -
-
-
-

Loading hosts...

-
-
- ); - } - - if (error) { - return ( -
-
-

{error}

- -
-
- ); - } - - if (hosts.length === 0) { - return ( -
-
- -

No SSH Hosts

-

- You haven't added any SSH hosts yet. Click "Add Host" to get started. -

-
-
- ); - } - - return ( -
-
-
-

SSH Hosts

-

- {filteredAndSortedHosts.length} hosts -

-
- -
- -
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
- - -
- {Object.entries(hostsByFolder).map(([folder, folderHosts]) => ( -
- - - -
- - {folder} - - {folderHosts.length} - -
-
- -
- {folderHosts.map((host) => ( -
handleEdit(host)} - > -
-
-
- {host.pin && } -

- {host.name || `${host.username}@${host.ip}`} -

-
-

- {host.ip}:{host.port} -

-

- {host.username} -

-
-
- - -
-
- -
- {host.tags && host.tags.length > 0 && ( -
- {host.tags.slice(0, 6).map((tag, index) => ( - - - {tag} - - ))} - {host.tags.length > 6 && ( - - +{host.tags.length - 6} - - )} -
- )} - -
- {host.enableTerminal && ( - - - Terminal - - )} - {host.enableTunnel && ( - - - Tunnel - {host.tunnelConnections && host.tunnelConnections.length > 0 && ( - ({host.tunnelConnections.length}) - )} - - )} - {host.enableConfigEditor && ( - - - Config - - )} -
-
-
- ))} -
-
-
-
-
- ))} -
-
-
- ); -} \ 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/SSH.tsx b/src/apps/SSH/Terminal/SSH.tsx deleted file mode 100644 index 002be4ca..00000000 --- a/src/apps/SSH/Terminal/SSH.tsx +++ /dev/null @@ -1,778 +0,0 @@ -import React, {useState, useRef, useEffect} from "react"; -import {SSHSidebar} from "@/apps/SSH/Terminal/SSHSidebar.tsx"; -import {SSHTerminal} from "./SSHTerminal.tsx"; -import {SSHTopbar} from "@/apps/SSH/Terminal/SSHTopbar.tsx"; -import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx'; -import * as ResizablePrimitive from "react-resizable-panels"; - -interface ConfigEditorProps { - onSelectView: (view: string) => void; -} - -type Tab = { - id: number; - title: string; - hostConfig: any; - terminalRef: React.RefObject; -}; - -export function SSH({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 = 6; - - 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 ( -
- {/* Sidebar (collapsible) */} -
- { - 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, - }} - title="Show top bar" - /> - )} - - {/* Main terminal area (height adapts to topbar) */} -
- {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()} -
-
- - {/* Sidebar reopen handle */} - {!isSidebarOpen && ( -
setIsSidebarOpen(true)} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: HANDLE_THICKNESS, - height: '100%', - background: '#222224', - cursor: 'pointer', - zIndex: 20, - }} - title="Show sidebar" - /> - )} -
- ); -} \ No newline at end of file diff --git a/src/apps/SSH/Terminal/SSHSidebar.tsx b/src/apps/SSH/Terminal/SSHSidebar.tsx deleted file mode 100644 index e92dbae5..00000000 --- a/src/apps/SSH/Terminal/SSHSidebar.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 SSHSidebar({ - 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 - -