├── .dockerignore ├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── CNAME ├── LICENSE ├── README.md ├── docker ├── Dockerfile ├── entrypoint.sh └── nginx.conf ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── fonts │ └── nerdfont.css ├── logo192.png └── logo512.png ├── repo-images ├── DemoImage1.png ├── DemoImage2.png └── TermixLogo.png ├── src ├── App.jsx ├── apps │ ├── Launchpad.jsx │ ├── hosts │ │ ├── HostViewer.jsx │ │ ├── rdp │ │ │ └── RDPTerminal.jsx │ │ ├── sftp │ │ │ └── SFTPTerminal.jsx │ │ ├── ssh │ │ │ └── SSHTerminal.jsx │ │ └── vnc │ │ │ └── VNCTerminal.jsx │ ├── snippets │ │ └── SnippetViewer.jsx │ └── user │ │ └── User.jsx ├── backend │ ├── database.cjs │ ├── rdp.cjs │ ├── sftp.cjs │ ├── ssh.cjs │ ├── starter.cjs │ └── vnc.cjs ├── images │ ├── host_viewer_icon.png │ ├── launchpad_rocket.png │ ├── profile_icon.png │ ├── snippets_icon.png │ └── termix_icon.png ├── index.css ├── main.jsx ├── modals │ ├── AddHostModal.jsx │ ├── AdminModal.jsx │ ├── AuthModal.jsx │ ├── ConfirmDeleteModal.jsx │ ├── EditHostModal.jsx │ ├── ErrorModal.jsx │ ├── InfoModal.jsx │ ├── NoAuthenticationModal.jsx │ ├── ProfileModal.jsx │ └── ShareHostModal.jsx ├── other │ ├── Utils.jsx │ └── eventBus.jsx ├── theme.js ├── ui │ └── TabList.jsx └── utils │ ├── cssLoader.js │ └── fontLoader.js └── vite.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .github 4 | .gitignore 5 | 6 | # Build and dependency directories 7 | node_modules 8 | dist 9 | coverage 10 | .cache 11 | .npm 12 | 13 | # Data directory 14 | data 15 | 16 | # IDE specific files 17 | .idea 18 | .vscode 19 | *.sublime-project 20 | *.sublime-workspace 21 | 22 | # Logs 23 | logs 24 | *.log 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # Environment variables 30 | .env 31 | .env.* 32 | !.env.example 33 | 34 | # OS specific files 35 | .DS_Store 36 | Thumbs.db 37 | 38 | # Repository specific 39 | repo-images 40 | README.md 41 | LICENSE -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - development 7 | paths-ignore: 8 | - '**.md' 9 | - '.gitignore' 10 | workflow_dispatch: 11 | inputs: 12 | tag_name: 13 | description: "Custom tag name for the Docker image" 14 | required: false 15 | default: "" 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 1 25 | 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | with: 29 | platforms: arm64 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | with: 34 | platforms: linux/amd64,linux/arm64 35 | driver-opts: | 36 | image=moby/buildkit:master 37 | network=host 38 | 39 | - name: Cache npm dependencies 40 | uses: actions/cache@v3 41 | with: 42 | path: | 43 | ~/.npm 44 | node_modules 45 | */*/node_modules 46 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 47 | restore-keys: | 48 | ${{ runner.os }}-node- 49 | 50 | - name: Cache Docker layers 51 | uses: actions/cache@v3 52 | with: 53 | path: /tmp/.buildx-cache 54 | key: ${{ runner.os }}-buildx-${{ github.sha }} 55 | restore-keys: | 56 | ${{ runner.os }}-buildx- 57 | 58 | - name: Login to Docker Registry 59 | uses: docker/login-action@v3 60 | with: 61 | registry: ghcr.io 62 | username: ${{ github.actor }} 63 | password: ${{ secrets.GITHUB_TOKEN }} 64 | 65 | - name: Determine Docker image tag 66 | run: | 67 | echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 68 | if [ "${{ github.event.inputs.tag_name }}" == "" ]; then 69 | IMAGE_TAG="${{ github.ref_name }}-development-latest" 70 | else 71 | IMAGE_TAG="${{ github.event.inputs.tag_name }}" 72 | fi 73 | echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV 74 | 75 | - name: Build and Push Multi-Arch Docker Image 76 | uses: docker/build-push-action@v5 77 | with: 78 | context: . 79 | file: ./docker/Dockerfile 80 | push: true 81 | platforms: linux/amd64,linux/arm64 82 | tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ env.IMAGE_TAG }} 83 | labels: | 84 | org.opencontainers.image.source=https://github.com/${{ github.repository }} 85 | org.opencontainers.image.revision=${{ github.sha }} 86 | cache-from: type=local,src=/tmp/.buildx-cache 87 | cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max 88 | build-args: | 89 | BUILDKIT_INLINE_CACHE=1 90 | BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 91 | outputs: type=registry,compression=zstd,compression-level=19 92 | 93 | - name: Move cache 94 | run: | 95 | rm -rf /tmp/.buildx-cache 96 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 97 | 98 | - name: Notify via ntfy 99 | if: success() 100 | run: | 101 | curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \ 102 | https://ntfy.karmaa.site/termix-build 103 | 104 | - name: Delete all untagged image versions 105 | if: success() 106 | uses: quartx-analytics/ghcr-cleaner@v1 107 | with: 108 | owner-type: user 109 | token: ${{ secrets.GHCR_TOKEN }} 110 | repository-owner: ${{ github.repository_owner }} 111 | delete-untagged: true 112 | 113 | - name: Cleanup Docker Images Locally 114 | if: always() 115 | run: | 116 | docker image prune -af 117 | docker system prune -af --volumes -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Diagnostic reports (https://nodejs.org/api/report.html) 27 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 28 | 29 | # Runtime data 30 | pids 31 | *.pid 32 | *.seed 33 | *.pid.lock 34 | 35 | # Directory for instrumented libs generated by jscoverage/JSCover 36 | lib-cov 37 | 38 | # Coverage directory used by tools like istanbul 39 | coverage 40 | *.lcov 41 | 42 | # nyc test coverage 43 | .nyc_output 44 | 45 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 46 | .grunt 47 | 48 | # Bower dependency directory (https://bower.io/) 49 | bower_components 50 | 51 | # node-waf configuration 52 | .lock-wscript 53 | 54 | # Compiled binary addons (https://nodejs.org/api/addons.html) 55 | build/Release 56 | 57 | # Dependency directories 58 | node_modules/ 59 | jspm_packages/ 60 | 61 | # TypeScript v1 declaration files 62 | typings/ 63 | 64 | # TypeScript cache 65 | *.tsbuildinfo 66 | 67 | # Optional npm cache directory 68 | .npm 69 | 70 | # Optional eslint cache 71 | .eslintcache 72 | 73 | # Microbundle cache 74 | .rpt2_cache/ 75 | .rts2_cache_cjs/ 76 | .rts2_cache_es/ 77 | .rts2_cache_umd/ 78 | 79 | # Optional REPL history 80 | .node_repl_history 81 | 82 | # Output of 'npm pack' 83 | *.tgz 84 | 85 | # Yarn Integrity file 86 | .yarn-integrity 87 | 88 | # dotenv environment variables file 89 | .env 90 | .env.test 91 | 92 | # parcel-bundler cache (https://parceljs.org/) 93 | .cache 94 | 95 | # Next.js build output 96 | .next 97 | 98 | # Nuxt.js build / generate output 99 | .nuxt 100 | 101 | # Gatsby files 102 | .cache/ 103 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 104 | # https://nextjs.org/blog/next-9-1#public-directory-support 105 | # public 106 | 107 | # vuepress build output 108 | .vuepress/dist 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 122 | 123 | # dependencies 124 | /node_modules 125 | /.pnp 126 | .pnp.js 127 | 128 | # testing 129 | /coverage 130 | 131 | # production 132 | /build 133 | 134 | # misc 135 | .env.local 136 | .env.development.local 137 | .env.test.local 138 | .env.production.local 139 | 140 | .bash_history 141 | .bashrc 142 | .init_done 143 | .profile 144 | .sudo_as_admin_successful 145 | .wget-hsts 146 | .git-credentials 147 | .docker/ 148 | .bash_logout 149 | 150 | # VSCode Files 151 | .vscode-server/ 152 | 153 | # Configs 154 | .config/ 155 | 156 | # .dotnet 157 | .dotnet/ 158 | 159 | # .local 160 | .local/ 161 | /docker/docker-compose.yml 162 | /src/data/ 163 | /docker/mongodb/ 164 | /docker/docker-compose.yml 165 | /data/termix.db 166 | /data/settings.json 167 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | termix.site -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Luke Gustafson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Repo Stats 2 | ![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars) 3 | ![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) 4 | ![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) 5 | Discord 6 | #### Top Technologies 7 | [![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#) 8 | [![Javascript Badge](https://img.shields.io/badge/-Javascript-F0DB4F?style=flat-square&labelColor=black&logo=javascript&logoColor=F0DB4F)](#) 9 | [![Nodejs Badge](https://img.shields.io/badge/-Nodejs-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#) 10 | [![HTML Badge](https://img.shields.io/badge/-HTML-E34F26?style=flat-square&labelColor=black&logo=html5&logoColor=E34F26)](#) 11 | [![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#) 12 | [![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#) 13 | [![MongoDB Badge](https://img.shields.io/badge/-MongoDB-47A248?style=flat-square&labelColor=black&logo=mongodb&logoColor=47A248)](#) 14 | [![MUI Joy Badge](https://img.shields.io/badge/-MUI%20Joy-007FFF?style=flat-square&labelColor=black&logo=mui&logoColor=007FFF)](#) 15 | 16 | 17 |
18 |

19 | 20 | Termix Banner 21 |

22 | 23 | If you would like, you can support the project here!\ 24 | [![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/LukeGustafson803) 25 | 26 | # Overview 27 | Termix is an open-source forever free self-hosted SSH (other protocols planned, see [Planned Features](#planned-features)) server management panel inspired by [Nexterm](https://github.com/gnmyt/Nexterm). Its purpose is to provide an all-in-one docker-hosted web solution to manage your servers in one easy place. I'm using this project to help me learn [React](https://github.com/facebook/react), [Vite](https://github.com/vitejs/vite-plugin-react), and [Docker](https://www.docker.com) but also because I could never settle on a server management software that I enjoyed to use. 28 | 29 | > [!WARNING] 30 | > This app is in the VERY early stages of development. Expect bugs, data loss, and unexplainable issues! For that reason, I recommend you securely tunnel your connection to Termix through a VPN. 31 | 32 | # Features 33 | - SSH 34 | - Split Screen (Up to 4) & Tab System 35 | - User Authentication 36 | - Save Hosts (and easily view, connect, and manage them) 37 | - Terminal Themes 38 | 39 | # Planned Features 40 | - VNC 41 | - RDP 42 | - SFTP (build in file transfer) 43 | - ChatGPT/Ollama Integration (for commands) 44 | - Apps (like notes, AI, etc) 45 | - User Management (roles, permissions, etc.) 46 | - SSH Tunneling 47 | - More Authentication Methods 48 | - More Security Features (like 2FA, etc.) 49 | 50 | # Installation 51 | Visit the Termix [Wiki](https://github.com/LukeGus/Termix/wiki) for information on how to install Termix. You can also use these links to go directly to guide. [Docker](https://github.com/LukeGus/Termix/wiki/Docker) or [Manual](https://github.com/LukeGus/Termix/wiki/Manual). 52 | 53 | # Support 54 | If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo. If you would like to support me financially, you can on [Paypal](https://paypal.me/LukeGustafson803). 55 | 56 | # Show-off 57 | 58 | ![Demo Image](repo-images/DemoImage1.png) 59 | ![Demo Image](repo-images/DemoImage2.png) 60 | 61 | # License 62 | Distributed under the MIT license. See LICENSE for more information. 63 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Install dependencies and build frontend 2 | FROM node:18-alpine AS deps 3 | WORKDIR /app 4 | 5 | # Copy dependency files 6 | COPY package*.json ./ 7 | 8 | # Install dependencies with caching 9 | RUN npm ci --force && \ 10 | npm cache clean --force 11 | 12 | # Stage 2: Build frontend 13 | FROM deps AS frontend-builder 14 | WORKDIR /app 15 | 16 | # Copy source files 17 | COPY . . 18 | 19 | # Build frontend 20 | RUN npm run build 21 | 22 | # Stage 3: Production dependencies 23 | FROM node:18-alpine AS production-deps 24 | WORKDIR /app 25 | 26 | # Copy only production dependency files 27 | COPY package*.json ./ 28 | 29 | # Install only production dependencies 30 | RUN npm ci --only=production --ignore-scripts --force && \ 31 | npm cache clean --force 32 | 33 | # Stage 4: Build native modules 34 | FROM node:18-alpine AS native-builder 35 | WORKDIR /app 36 | 37 | # Install build dependencies 38 | RUN apk add --no-cache python3 make g++ 39 | 40 | # Copy dependency files 41 | COPY package*.json ./ 42 | 43 | # Install only the native modules we need 44 | RUN npm ci --only=production bcrypt better-sqlite3 --force && \ 45 | npm cache clean --force 46 | 47 | # Stage 5: Final image 48 | FROM node:18-alpine 49 | ENV DATA_DIR=/app/data \ 50 | PORT=8080 51 | 52 | # Install dependencies in a single layer 53 | RUN apk add --no-cache nginx gettext su-exec && \ 54 | mkdir -p /app/data && \ 55 | chown -R node:node /app/data 56 | 57 | # Setup nginx and frontend 58 | COPY docker/nginx.conf /etc/nginx/nginx.conf 59 | COPY --from=frontend-builder /app/dist /usr/share/nginx/html 60 | RUN chown -R nginx:nginx /usr/share/nginx/html 61 | 62 | # Setup backend 63 | WORKDIR /app 64 | COPY package*.json ./ 65 | 66 | # Copy production dependencies and native modules 67 | COPY --from=production-deps /app/node_modules /app/node_modules 68 | COPY --from=native-builder /app/node_modules/bcrypt /app/node_modules/bcrypt 69 | COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3 70 | 71 | # Copy backend source 72 | COPY src/backend/ ./src/backend/ 73 | RUN chown -R node:node /app 74 | 75 | VOLUME ["/app/data"] 76 | # Expose ports 77 | EXPOSE ${PORT} 8081 8082 8083 8084 8085 78 | 79 | COPY docker/entrypoint.sh /entrypoint.sh 80 | RUN chmod +x /entrypoint.sh 81 | CMD ["/entrypoint.sh"] -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | export PORT=${PORT:-8080} 5 | echo "Configuring web UI to run on port: $PORT" 6 | 7 | envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp 8 | mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf 9 | 10 | mkdir -p /app/data 11 | chown -R node:node /app/data 12 | chmod 755 /app/data 13 | 14 | echo "Starting nginx..." 15 | nginx 16 | 17 | # Start backend services 18 | echo "Starting backend services..." 19 | cd /app 20 | export NODE_ENV=production 21 | 22 | if command -v su-exec > /dev/null 2>&1; then 23 | su-exec node node src/backend/starter.cjs 24 | else 25 | su -s /bin/sh node -c "node src/backend/starter.cjs" 26 | fi 27 | 28 | echo "All services started" 29 | 30 | tail -f /dev/null -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | http { 6 | include mime.types; 7 | default_type application/octet-stream; 8 | 9 | sendfile on; 10 | keepalive_timeout 65; 11 | 12 | server { 13 | listen ${PORT}; 14 | server_name localhost; 15 | 16 | location / { 17 | root /usr/share/nginx/html; 18 | index index.html index.htm; 19 | } 20 | 21 | location /database.io/ { 22 | proxy_pass http://127.0.0.1:8081; 23 | proxy_http_version 1.1; 24 | proxy_set_header Upgrade $http_upgrade; 25 | proxy_set_header Connection "Upgrade"; 26 | proxy_set_header Host $host; 27 | proxy_cache_bypass $http_upgrade; 28 | 29 | proxy_set_header X-Real-IP $remote_addr; 30 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 31 | proxy_set_header X-Forwarded-Proto $scheme; 32 | } 33 | 34 | location /ssh.io/ { 35 | proxy_pass http://127.0.0.1:8082; 36 | proxy_http_version 1.1; 37 | proxy_set_header Upgrade $http_upgrade; 38 | proxy_set_header Connection "Upgrade"; 39 | proxy_set_header Host $host; 40 | proxy_cache_bypass $http_upgrade; 41 | 42 | proxy_set_header X-Real-IP $remote_addr; 43 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 44 | proxy_set_header X-Forwarded-Proto $scheme; 45 | } 46 | 47 | location /rdp.io/ { 48 | proxy_pass http://127.0.0.1:8083; 49 | proxy_http_version 1.1; 50 | proxy_set_header Upgrade $http_upgrade; 51 | proxy_set_header Connection "Upgrade"; 52 | proxy_set_header Host $host; 53 | proxy_cache_bypass $http_upgrade; 54 | 55 | proxy_set_header X-Real-IP $remote_addr; 56 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 57 | proxy_set_header X-Forwarded-Proto $scheme; 58 | } 59 | 60 | location /vnc.io/ { 61 | proxy_pass http://127.0.0.1:8084; 62 | proxy_http_version 1.1; 63 | proxy_set_header Upgrade $http_upgrade; 64 | proxy_set_header Connection "Upgrade"; 65 | proxy_set_header Host $host; 66 | proxy_cache_bypass $http_upgrade; 67 | 68 | proxy_set_header X-Real-IP $remote_addr; 69 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 70 | proxy_set_header X-Forwarded-Proto $scheme; 71 | } 72 | 73 | location /sftp.io/ { 74 | proxy_pass http://127.0.0.1:8085; 75 | proxy_http_version 1.1; 76 | proxy_set_header Upgrade $http_upgrade; 77 | proxy_set_header Connection "Upgrade"; 78 | proxy_set_header Host $host; 79 | proxy_cache_bypass $http_upgrade; 80 | 81 | proxy_set_header X-Real-IP $remote_addr; 82 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 83 | proxy_set_header X-Forwarded-Proto $scheme; 84 | } 85 | 86 | error_page 500 502 503 504 /50x.html; 87 | location = /50x.html { 88 | root /usr/share/nginx/html; 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import react from 'eslint-plugin-react' 4 | import reactHooks from 'eslint-plugin-react-hooks' 5 | import reactRefresh from 'eslint-plugin-react-refresh' 6 | 7 | export default [ 8 | { ignores: ['dist'] }, 9 | { 10 | files: ['**/*.{js,jsx}'], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | parserOptions: { 15 | ecmaVersion: 'latest', 16 | ecmaFeatures: { jsx: true }, 17 | sourceType: 'module', 18 | }, 19 | }, 20 | settings: { react: { version: '18.3' } }, 21 | plugins: { 22 | react, 23 | 'react-hooks': reactHooks, 24 | 'react-refresh': reactRefresh, 25 | }, 26 | rules: { 27 | ...js.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs['jsx-runtime'].rules, 30 | ...reactHooks.configs.recommended.rules, 31 | 'react/jsx-no-target-blank': 'off', 32 | 'react-refresh/only-export-components': [ 33 | 'warn', 34 | { allowConstantExport: true }, 35 | ], 36 | }, 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Termix 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "termix", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@fontsource/inter": "^5.2.5", 14 | "@mui/icons-material": "^6.4.7", 15 | "@mui/joy": "^5.0.0-beta.51", 16 | "@tailwindcss/vite": "^4.0.15", 17 | "@xterm/addon-fit": "^0.10.0", 18 | "@xterm/addon-web-links": "^0.11.0", 19 | "@xterm/xterm": "^5.5.0", 20 | "bcrypt": "^5.1.1", 21 | "better-sqlite3": "^11.9.1", 22 | "cors": "^2.8.5", 23 | "crypto": "^1.0.1", 24 | "dayjs": "^1.11.13", 25 | "dotenv": "^16.4.7", 26 | "embla-carousel-react": "^7.1.0", 27 | "express": "^4.21.2", 28 | "is-stream": "^4.0.1", 29 | "make-dir": "^5.0.0", 30 | "mitt": "^3.0.1", 31 | "node-ssh": "^13.2.0", 32 | "prop-types": "^15.8.1", 33 | "react": "^18.3.1", 34 | "react-dom": "^18.3.1", 35 | "socket.io": "^4.8.1", 36 | "socket.io-client": "^4.8.1", 37 | "ssh2": "^1.16.0", 38 | "tailwindcss": "^4.0.15" 39 | }, 40 | "devDependencies": { 41 | "@eslint/js": "^9.17.0", 42 | "@types/react": "^18.3.18", 43 | "@types/react-dom": "^18.3.5", 44 | "@vitejs/plugin-react": "^4.3.4", 45 | "eslint": "^9.17.0", 46 | "eslint-plugin-react": "^7.37.2", 47 | "eslint-plugin-react-hooks": "^5.0.0", 48 | "eslint-plugin-react-refresh": "^0.4.16", 49 | "globals": "^15.14.0", 50 | "vite": "^6.0.5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/nerdfont.css: -------------------------------------------------------------------------------- 1 | /* Nerd Fonts CSS */ 2 | 3 | /* Symbols Nerd Font (just symbols) */ 4 | @font-face { 5 | font-family: 'Symbols Nerd Font'; 6 | font-style: normal; 7 | font-weight: 400; 8 | font-display: block; 9 | src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@master/patched-fonts/NerdFontsSymbolsOnly/SymbolsNerdFont-Regular.ttf') format('truetype'); 10 | } 11 | 12 | @font-face { 13 | font-family: 'Symbols Nerd Font Mono'; 14 | font-style: normal; 15 | font-weight: 400; 16 | font-display: block; 17 | src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@master/patched-fonts/NerdFontsSymbolsOnly/SymbolsNerdFontMono-Regular.ttf') format('truetype'); 18 | } 19 | 20 | /* Hack Nerd Font */ 21 | @font-face { 22 | font-family: 'Hack Nerd Font'; 23 | font-style: normal; 24 | font-weight: 400; 25 | font-display: swap; 26 | src: url('https://github.com/ryanoasis/nerd-fonts/raw/master/patched-fonts/Hack/Regular/complete/Hack%20Regular%20Nerd%20Font%20Complete.ttf') format('truetype'); 27 | } 28 | 29 | @font-face { 30 | font-family: 'Hack Nerd Font Mono'; 31 | font-style: normal; 32 | font-weight: 400; 33 | font-display: swap; 34 | src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@master/patched-fonts/Hack/Regular/HackNerdFontMono-Regular.ttf') format('truetype'); 35 | } 36 | 37 | /* FiraCode Nerd Font */ 38 | @font-face { 39 | font-family: 'FiraCode Nerd Font'; 40 | font-style: normal; 41 | font-weight: 400; 42 | font-display: swap; 43 | src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@master/patched-fonts/FiraCode/Regular/FiraCodeNerdFont-Regular.ttf') format('truetype'); 44 | } 45 | 46 | @font-face { 47 | font-family: 'FiraCode Nerd Font Mono'; 48 | font-style: normal; 49 | font-weight: 400; 50 | font-display: swap; 51 | src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@master/patched-fonts/FiraCode/Regular/FiraCodeNerdFontMono-Regular.ttf') format('truetype'); 52 | } 53 | 54 | /* JetBrains Mono Nerd Font */ 55 | @font-face { 56 | font-family: 'JetBrainsMono Nerd Font'; 57 | font-style: normal; 58 | font-weight: 400; 59 | font-display: swap; 60 | src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@master/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf') format('truetype'); 61 | } 62 | 63 | @font-face { 64 | font-family: 'JetBrainsMono Nerd Font Mono'; 65 | font-style: normal; 66 | font-weight: 400; 67 | font-display: swap; 68 | src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@master/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf') format('truetype'); 69 | } 70 | 71 | /* CSS for terminal with Nerd Fonts */ 72 | .terminal-nerd-font .xterm-rows span { 73 | font-family: 'Hack Nerd Font', 'Symbols Nerd Font', monospace !important; 74 | font-variant-ligatures: no-contextual !important; 75 | text-rendering: optimizeLegibility !important; 76 | font-feature-settings: "liga" 0, "calt" 0, "dlig" 0 !important; 77 | } 78 | 79 | /* Fix specific Nerd Font icons and ensure they render correctly */ 80 | .terminal-nerd-font .xterm-rows span { 81 | font-variant-ligatures: no-contextual !important; 82 | text-rendering: optimizeLegibility !important; 83 | font-feature-settings: "liga" 0, "calt" 0, "dlig" 0 !important; 84 | } 85 | 86 | /* All Terminal Fonts */ 87 | 88 | /* Very different fonts - each with a unique appearance */ 89 | 90 | /* Fira Code - Distinctively programmer-focused with ligatures */ 91 | @font-face { 92 | font-family: 'Fira Code'; 93 | font-style: normal; 94 | font-weight: 400; 95 | font-display: swap; 96 | src: url('https://fonts.gstatic.com/s/firacode/v22/uU9eCBsR6Z2vfE9aq3bL0fxyUs4tcw4W_D1sJV37Nv7g.woff2') format('woff2'); 97 | } 98 | 99 | /* Ubuntu Mono - Rounded and wider */ 100 | @font-face { 101 | font-family: 'Ubuntu Mono'; 102 | font-style: normal; 103 | font-weight: 400; 104 | font-display: swap; 105 | src: url('https://fonts.gstatic.com/s/ubuntumono/v15/KFOjCneDtsqEr0keqCMhbCc6CsQ.woff2') format('woff2'); 106 | } 107 | 108 | /* JetBrains Mono - Square and very readable */ 109 | @font-face { 110 | font-family: 'JetBrains Mono'; 111 | font-style: normal; 112 | font-weight: 400; 113 | font-display: swap; 114 | src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxTOlOV.woff2') format('woff2'); 115 | } 116 | 117 | /* IBM Plex Mono - Angular and distinct */ 118 | @font-face { 119 | font-family: 'IBM Plex Mono'; 120 | font-style: normal; 121 | font-weight: 400; 122 | font-display: swap; 123 | src: url('https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1i8q131nj-o.woff2') format('woff2'); 124 | } 125 | 126 | /* Anonymous Pro - Old-school terminal look */ 127 | @font-face { 128 | font-family: 'Anonymous Pro'; 129 | font-style: normal; 130 | font-weight: 400; 131 | font-display: swap; 132 | src: url('https://fonts.gstatic.com/s/anonymouspro/v21/rP2Bp2a15UIB-sM7tDNW65jPCaA.woff2') format('woff2'); 133 | } 134 | 135 | /* Inconsolata - Geometric and distinctive */ 136 | @font-face { 137 | font-family: 'Inconsolata'; 138 | font-style: normal; 139 | font-weight: 400; 140 | font-display: swap; 141 | src: url('https://fonts.gstatic.com/s/inconsolata/v31/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwEYxs.woff2') format('woff2'); 142 | } 143 | 144 | /* Courier Prime - Classic courier look */ 145 | @font-face { 146 | font-family: 'Courier Prime'; 147 | font-style: normal; 148 | font-weight: 400; 149 | font-display: swap; 150 | src: url('https://fonts.gstatic.com/s/courierprime/v9/u-450q2lgwslOqpF_6gQ8kELaw9pWt_-.woff2') format('woff2'); 151 | } 152 | 153 | /* Space Mono - Very wide and distinct */ 154 | @font-face { 155 | font-family: 'Space Mono'; 156 | font-style: normal; 157 | font-weight: 400; 158 | font-display: swap; 159 | src: url('https://fonts.gstatic.com/s/spacemono/v13/i7dPIFZifjKcF5UAWdDRYEF8RQ.woff2') format('woff2'); 160 | } 161 | 162 | /* B612 Mono - Aeronautical design, distinctive */ 163 | @font-face { 164 | font-family: 'B612 Mono'; 165 | font-style: normal; 166 | font-weight: 400; 167 | font-display: swap; 168 | src: url('https://fonts.gstatic.com/s/b612mono/v13/kmK_Zq85QVWbN1eW6lJV0A7d.woff2') format('woff2'); 169 | } -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/public/logo512.png -------------------------------------------------------------------------------- /repo-images/DemoImage1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/repo-images/DemoImage1.png -------------------------------------------------------------------------------- /repo-images/DemoImage2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/repo-images/DemoImage2.png -------------------------------------------------------------------------------- /repo-images/TermixLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/repo-images/TermixLogo.png -------------------------------------------------------------------------------- /src/apps/Launchpad.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { useEffect, useRef, useState } from 'react'; 3 | import { CssVarsProvider } from '@mui/joy/styles'; 4 | import { Button } from '@mui/joy'; 5 | import HostViewerIcon from '../images/host_viewer_icon.png'; 6 | import SnippetsIcon from '../images/snippets_icon.png'; 7 | import theme from '../theme.js'; 8 | import HostViewer from './hosts/HostViewer.jsx'; 9 | import SnippetViewer from './snippets/SnippetViewer.jsx'; 10 | 11 | function Launchpad({ 12 | onClose, 13 | getHosts, 14 | getSnippets, 15 | connectToHost, 16 | isAddHostHidden, 17 | setIsAddHostHidden, 18 | isEditHostHidden, 19 | isErrorHidden, 20 | isConfirmDeleteHidden, 21 | setIsConfirmDeleteHidden, 22 | deleteHost, 23 | editHost, 24 | shareHost, 25 | userRef, 26 | isHostViewerMenuOpen, 27 | setIsHostViewerMenuOpen, 28 | isSnippetViewerMenuOpen, 29 | setIsSnippetViewerMenuOpen, 30 | terminals, 31 | activeTab, 32 | }) { 33 | const launchpadRef = useRef(null); 34 | const [sidebarOpen, setSidebarOpen] = useState(false); 35 | const [activeApp, setActiveApp] = useState('hostViewer'); 36 | const [isAnyModalOpen, setIsAnyModalOpen] = useState(false); 37 | 38 | useEffect(() => { 39 | const handleClickOutside = (event) => { 40 | if ( 41 | launchpadRef.current && 42 | !launchpadRef.current.contains(event.target) && 43 | isAddHostHidden && 44 | isEditHostHidden && 45 | isErrorHidden && 46 | !isHostViewerMenuOpen && 47 | !isSnippetViewerMenuOpen && 48 | isConfirmDeleteHidden && 49 | !isAnyModalOpen 50 | ) { 51 | window.dispatchEvent(new CustomEvent('launchpad:close')); 52 | onClose(); 53 | } 54 | }; 55 | 56 | window.dispatchEvent(new CustomEvent('launchpad:open')); 57 | 58 | document.addEventListener("mousedown", handleClickOutside); 59 | 60 | return () => { 61 | document.removeEventListener("mousedown", handleClickOutside); 62 | window.dispatchEvent(new CustomEvent('launchpad:close')); 63 | }; 64 | }, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden, isHostViewerMenuOpen, isSnippetViewerMenuOpen, isConfirmDeleteHidden, isAnyModalOpen]); 65 | 66 | const handleModalOpen = () => { 67 | setIsAnyModalOpen(true); 68 | }; 69 | 70 | const handleModalClose = () => { 71 | setIsAnyModalOpen(false); 72 | }; 73 | 74 | return ( 75 | 76 |
91 |
105 | {/* Sidebar */} 106 |
122 | {/* Sidebar Toggle Button */} 123 | 144 | 145 | {/* HostViewer Button */} 146 | 188 | 189 | {/* SnippetViewer Button */} 190 | 231 |
232 | 233 | {/* Main Content */} 234 |
235 | {activeApp === 'hostViewer' && ( 236 | { 239 | try { 240 | if (!hostConfig || !hostConfig.ip || !hostConfig.user) return; 241 | 242 | connectToHost(hostConfig); 243 | 244 | setTimeout(() => onClose(), 100); 245 | } catch (error) { 246 | } 247 | }} 248 | setIsAddHostHidden={setIsAddHostHidden} 249 | deleteHost={deleteHost} 250 | editHost={editHost} 251 | openEditPanel={editHost} 252 | shareHost={shareHost} 253 | onModalOpen={handleModalOpen} 254 | onModalClose={handleModalClose} 255 | userRef={userRef} 256 | isMenuOpen={isHostViewerMenuOpen || false} 257 | setIsMenuOpen={setIsHostViewerMenuOpen} 258 | isEditHostHidden={isEditHostHidden} 259 | isConfirmDeleteHidden={isConfirmDeleteHidden} 260 | setIsConfirmDeleteHidden={setIsConfirmDeleteHidden} 261 | /> 262 | )} 263 | {activeApp === 'snippetViewer' && ( 264 | 274 | )} 275 |
276 |
277 |
278 |
279 | ); 280 | } 281 | 282 | Launchpad.propTypes = { 283 | onClose: PropTypes.func.isRequired, 284 | getHosts: PropTypes.func.isRequired, 285 | getSnippets: PropTypes.func.isRequired, 286 | connectToHost: PropTypes.func.isRequired, 287 | isAddHostHidden: PropTypes.bool.isRequired, 288 | setIsAddHostHidden: PropTypes.func.isRequired, 289 | isEditHostHidden: PropTypes.bool.isRequired, 290 | isErrorHidden: PropTypes.bool.isRequired, 291 | isConfirmDeleteHidden: PropTypes.bool, 292 | setIsConfirmDeleteHidden: PropTypes.func, 293 | deleteHost: PropTypes.func.isRequired, 294 | editHost: PropTypes.func.isRequired, 295 | shareHost: PropTypes.func.isRequired, 296 | userRef: PropTypes.object.isRequired, 297 | isHostViewerMenuOpen: PropTypes.bool, 298 | setIsHostViewerMenuOpen: PropTypes.func.isRequired, 299 | isSnippetViewerMenuOpen: PropTypes.bool, 300 | setIsSnippetViewerMenuOpen: PropTypes.func, 301 | terminals: PropTypes.array, 302 | activeTab: PropTypes.number, 303 | }; 304 | 305 | export default Launchpad; -------------------------------------------------------------------------------- /src/apps/hosts/HostViewer.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import { useState, useEffect, useRef } from "react"; 3 | import { Button, Input, Menu, MenuItem, IconButton, Chip } from "@mui/joy"; 4 | import ShareHostModal from "../../modals/ShareHostModal.jsx"; 5 | import ConfirmDeleteModal from "../../modals/ConfirmDeleteModal.jsx"; 6 | import { useTheme } from "@mui/material"; 7 | 8 | function HostViewer({ 9 | getHosts, 10 | connectToHost, 11 | setIsAddHostHidden, 12 | deleteHost, 13 | editHost, 14 | openEditPanel, 15 | shareHost, 16 | onModalOpen, 17 | onModalClose, 18 | userRef, 19 | isMenuOpen, 20 | setIsMenuOpen, 21 | isEditHostHidden, 22 | isConfirmDeleteHidden, 23 | setIsConfirmDeleteHidden, 24 | }) { 25 | const [hosts, setHosts] = useState([]); 26 | const [filteredHosts, setFilteredHosts] = useState([]); 27 | const [isLoading, setIsLoading] = useState(true); 28 | const [searchTerm, setSearchTerm] = useState(""); 29 | const [collapsedFolders, setCollapsedFolders] = useState(new Set()); 30 | const [draggedHost, setDraggedHost] = useState(null); 31 | const [isDraggingOver, setIsDraggingOver] = useState(null); 32 | const isMounted = useRef(true); 33 | const [deletingHostId, setDeletingHostId] = useState(null); 34 | const [isShareModalHidden, setIsShareModalHidden] = useState(true); 35 | const [selectedHostForShare, setSelectedHostForShare] = useState(null); 36 | const [selectedHost, setSelectedHost] = useState(null); 37 | const [selectedTags, setSelectedTags] = useState(new Set()); 38 | const anchorEl = useRef(null); 39 | const menuRef = useRef(null); 40 | const [activeMenuButton, setActiveMenuButton] = useState(null); 41 | const [lastPinnedHost, setLastPinnedHost] = useState(null); 42 | const [isPinningInProgress, setIsPinningInProgress] = useState(false); 43 | const [editingHostId, setEditingHostId] = useState(null); 44 | const [hostToDelete, setHostToDelete] = useState(null); 45 | const theme = useTheme(); 46 | const editingTimeoutId = useRef(null); 47 | 48 | useEffect(() => { 49 | const handleClickOutside = (event) => { 50 | if (menuRef.current && !menuRef.current.contains(event.target) && anchorEl.current && !anchorEl.current.contains(event.target)) { 51 | setIsMenuOpen(false); 52 | setSelectedHost(null); 53 | } 54 | }; 55 | 56 | document.addEventListener('mousedown', handleClickOutside); 57 | 58 | return () => { 59 | document.removeEventListener('mousedown', handleClickOutside); 60 | }; 61 | }, []); 62 | 63 | useEffect(() => { 64 | 65 | const forceCloseMenuOnClick = () => { 66 | if (isMenuOpen) { 67 | setIsMenuOpen(false); 68 | setSelectedHost(null); 69 | setActiveMenuButton(null); 70 | anchorEl.current = null; 71 | } 72 | }; 73 | 74 | window.addEventListener('click', forceCloseMenuOnClick); 75 | return () => window.removeEventListener('click', forceCloseMenuOnClick); 76 | }, [isMenuOpen]); 77 | 78 | const fetchHosts = async () => { 79 | try { 80 | const savedHosts = await getHosts(); 81 | if (isMounted.current) { 82 | setHosts(savedHosts || []); 83 | setFilteredHosts(savedHosts || []); 84 | setIsLoading(false); 85 | } 86 | } catch (error) { 87 | if (isMounted.current) { 88 | setHosts([]); 89 | setFilteredHosts([]); 90 | setIsLoading(false); 91 | } 92 | } 93 | }; 94 | 95 | useEffect(() => { 96 | isMounted.current = true; 97 | fetchHosts(); 98 | 99 | const intervalId = setInterval(() => { 100 | fetchHosts(); 101 | }, 2000); 102 | 103 | return () => { 104 | isMounted.current = false; 105 | clearInterval(intervalId); 106 | }; 107 | }, []); 108 | 109 | 110 | useEffect(() => { 111 | if (hosts.length > 0) { 112 | const allFolders = hosts 113 | .map(host => host.config?.folder) 114 | .filter(Boolean); 115 | window.availableFolders = Array.from(new Set(allFolders)); 116 | 117 | 118 | 119 | hosts.forEach(host => { 120 | if (!host.tags && host.config?.tags) { 121 | host.tags = host.config.tags; 122 | } 123 | }); 124 | } 125 | }, [hosts]); 126 | 127 | useEffect(() => { 128 | const filtered = hosts.filter((hostWrapper) => { 129 | const hostConfig = hostWrapper.config || {}; 130 | return hostConfig.name?.toLowerCase().includes(searchTerm.toLowerCase()) || 131 | hostConfig.ip?.toLowerCase().includes(searchTerm.toLowerCase()) || 132 | hostConfig.folder?.toLowerCase().includes(searchTerm.toLowerCase()); 133 | }); 134 | setFilteredHosts(filtered); 135 | }, [searchTerm, hosts]); 136 | 137 | useEffect(() => { 138 | if (!isShareModalHidden) { 139 | onModalOpen(); 140 | } else { 141 | onModalClose(); 142 | } 143 | }, [isShareModalHidden, onModalOpen, onModalClose]); 144 | 145 | const toggleFolder = (folderName) => { 146 | setCollapsedFolders(prev => { 147 | const newSet = new Set(prev); 148 | if (newSet.has(folderName)) { 149 | newSet.delete(folderName); 150 | } else { 151 | newSet.add(folderName); 152 | } 153 | return newSet; 154 | }); 155 | }; 156 | 157 | const groupHostsByFolder = (hosts) => { 158 | const grouped = {}; 159 | const noFolder = []; 160 | 161 | const sortedHosts = [...hosts].sort((a, b) => { 162 | if (a.isPinned !== b.isPinned) { 163 | return b.isPinned - a.isPinned; 164 | } 165 | const nameA = (a.config?.name || a.config?.ip || '').toLowerCase(); 166 | const nameB = (b.config?.name || b.config?.ip || '').toLowerCase(); 167 | return nameA.localeCompare(nameB); 168 | }); 169 | 170 | sortedHosts.forEach(host => { 171 | const folder = host.config?.folder; 172 | if (folder) { 173 | if (!grouped[folder]) { 174 | grouped[folder] = []; 175 | } 176 | grouped[folder].push(host); 177 | } else { 178 | noFolder.push(host); 179 | } 180 | }); 181 | 182 | const sortedFolders = Object.keys(grouped).sort((a, b) => a.localeCompare(b)); 183 | 184 | return { grouped, sortedFolders, noFolder }; 185 | }; 186 | 187 | const filterHostsByTags = (hosts) => { 188 | if (selectedTags.size === 0) return hosts; 189 | 190 | return hosts.filter(host => { 191 | const hostTags = host.tags || host.config?.tags || []; 192 | return Array.from(selectedTags).every(tag => hostTags.includes(tag)); 193 | }); 194 | }; 195 | 196 | const getAllTags = (hosts) => { 197 | const tags = new Set(); 198 | hosts.forEach(host => { 199 | const hostTags = host.tags || host.config?.tags || []; 200 | hostTags.forEach(tag => tags.add(tag)); 201 | }); 202 | return Array.from(tags).sort(); 203 | }; 204 | 205 | const toggleTag = (tag) => { 206 | setSelectedTags(prev => { 207 | const newSet = new Set(prev); 208 | if (newSet.has(tag)) { 209 | newSet.delete(tag); 210 | } else { 211 | newSet.add(tag); 212 | } 213 | return newSet; 214 | }); 215 | }; 216 | 217 | const handleDragStart = (e, host) => { 218 | setDraggedHost(host); 219 | e.dataTransfer.setData('text/plain', ''); 220 | }; 221 | 222 | const handleDragOver = (e, folderName) => { 223 | e.preventDefault(); 224 | setIsDraggingOver(folderName); 225 | }; 226 | 227 | const handleDragLeave = () => { 228 | setIsDraggingOver(null); 229 | }; 230 | 231 | const handleDrop = async (e, targetFolder) => { 232 | e.preventDefault(); 233 | e.stopPropagation(); 234 | setIsDraggingOver(null); 235 | 236 | if (!draggedHost) return; 237 | 238 | if (draggedHost.config.folder === targetFolder) return; 239 | 240 | const newConfig = { 241 | ...draggedHost.config, 242 | folder: targetFolder 243 | }; 244 | 245 | try { 246 | await editHost(draggedHost.config, newConfig); 247 | await fetchHosts(); 248 | } catch (error) { 249 | } 250 | 251 | setDraggedHost(null); 252 | }; 253 | 254 | const handleDropOnNoFolder = async (e) => { 255 | e.preventDefault(); 256 | e.stopPropagation(); 257 | setIsDraggingOver(null); 258 | 259 | if (!draggedHost || !draggedHost.config.folder) return; 260 | 261 | const newConfig = { 262 | ...draggedHost.config, 263 | folder: null 264 | }; 265 | 266 | try { 267 | await editHost(draggedHost.config, newConfig); 268 | await fetchHosts(); 269 | } catch (error) { 270 | } 271 | 272 | setDraggedHost(null); 273 | }; 274 | 275 | const confirmDelete = async (hostWrapper) => { 276 | setHostToDelete(hostWrapper); 277 | setIsConfirmDeleteHidden(false); 278 | setIsMenuOpen(false); 279 | onModalOpen(); 280 | }; 281 | 282 | const handleDelete = async (e, hostWrapper) => { 283 | e?.stopPropagation(); 284 | if (deletingHostId === hostWrapper._id) return; 285 | 286 | setDeletingHostId(hostWrapper._id); 287 | setIsConfirmDeleteHidden(true); 288 | onModalClose(); 289 | try { 290 | if (hostWrapper.isOwner) { 291 | await deleteHost({ _id: hostWrapper._id }); 292 | } else { 293 | await userRef.current.removeShare(hostWrapper._id); 294 | } 295 | await new Promise(resolve => setTimeout(resolve, 500)); 296 | await fetchHosts(); 297 | } catch (error) { 298 | } finally { 299 | setDeletingHostId(null); 300 | setHostToDelete(null); 301 | } 302 | }; 303 | 304 | const handleShare = async (hostId, username) => { 305 | try { 306 | await shareHost(hostId, username); 307 | await fetchHosts(); 308 | } catch (error) { 309 | } 310 | }; 311 | 312 | const handlePinToggle = async (hostData) => { 313 | try { 314 | setIsPinningInProgress(true); 315 | 316 | 317 | const hostToToggle = JSON.parse(JSON.stringify(hostData)); 318 | const newIsPinned = !hostToToggle.isPinned; 319 | 320 | 321 | setLastPinnedHost(hostToToggle._id); 322 | 323 | 324 | setHosts(prevHosts => 325 | prevHosts.map(host => 326 | host._id === hostToToggle._id 327 | ? {...host, isPinned: newIsPinned} 328 | : host 329 | ) 330 | ); 331 | 332 | 333 | const newConfig = { 334 | ...hostToToggle.config, 335 | isPinned: newIsPinned 336 | }; 337 | 338 | 339 | if (userRef.current) { 340 | await new Promise((resolve, reject) => { 341 | const userId = userRef.current.getUser()?.id; 342 | const sessionToken = userRef.current.getUser()?.sessionToken; 343 | 344 | if (!userId || !sessionToken) { 345 | reject(new Error("Not authenticated")); 346 | return; 347 | } 348 | 349 | 350 | const socketRef = userRef.current.getSocketRef?.() || window.socketRef; 351 | if (socketRef) { 352 | socketRef.emit("editHost", { 353 | userId, 354 | sessionToken, 355 | oldHostConfig: hostToToggle.config, 356 | newHostConfig: newConfig, 357 | }, (response) => { 358 | if (response?.success) { 359 | resolve(response); 360 | } else { 361 | reject(new Error(response?.error || "Failed to update pin status")); 362 | } 363 | }); 364 | } else { 365 | 366 | editHost(hostToToggle.config, newConfig) 367 | .then(resolve) 368 | .catch(reject); 369 | } 370 | }); 371 | 372 | 373 | await fetchHosts(); 374 | 375 | } 376 | } catch (error) { 377 | 378 | 379 | setHosts(prevHosts => 380 | prevHosts.map(host => 381 | host._id === hostToToggle._id 382 | ? {...host, isPinned: hostToToggle.isPinned} 383 | : host 384 | ) 385 | ); 386 | } finally { 387 | 388 | setTimeout(() => { 389 | setIsPinningInProgress(false); 390 | setLastPinnedHost(null); 391 | }, 500); 392 | } 393 | }; 394 | 395 | const handleEditHost = async (oldConfig, newConfig = null) => { 396 | try { 397 | if (editingTimeoutId.current) { 398 | clearTimeout(editingTimeoutId.current); 399 | editingTimeoutId.current = null; 400 | } 401 | 402 | if (!oldConfig) { 403 | return; 404 | } 405 | 406 | let hostId = oldConfig._id || oldConfig.id; 407 | let hostToEdit = null; 408 | 409 | if (hostId) { 410 | hostToEdit = hosts.find(host => host._id === hostId || host.id === hostId); 411 | } 412 | 413 | if (!hostToEdit) { 414 | hostToEdit = selectedHost; 415 | } 416 | 417 | if (!hostToEdit || (!hostToEdit._id && !hostToEdit.id)) { 418 | hostToEdit = hosts.find(host => 419 | host.config && host.config.ip === oldConfig.ip && 420 | host.config.user === oldConfig.user 421 | ); 422 | } 423 | 424 | if (!hostToEdit || (!hostToEdit._id && !hostToEdit.id)) { 425 | return; 426 | } 427 | 428 | const editingId = hostToEdit._id || hostToEdit.id; 429 | setEditingHostId(editingId); 430 | 431 | if (!newConfig) { 432 | const configWithConnectionType = { 433 | ...oldConfig, 434 | _id: editingId, 435 | id: editingId, 436 | connectionType: oldConfig.connectionType || 'ssh' 437 | }; 438 | openEditPanel(configWithConnectionType); 439 | return; 440 | } 441 | 442 | if (!newConfig.tags && oldConfig.tags) { 443 | newConfig.tags = oldConfig.tags; 444 | } 445 | 446 | newConfig._id = editingId; 447 | newConfig.id = editingId; 448 | oldConfig._id = editingId; 449 | oldConfig.id = editingId; 450 | 451 | if (!newConfig.connectionType && oldConfig.connectionType) { 452 | newConfig.connectionType = oldConfig.connectionType; 453 | } 454 | 455 | const result = await userRef.current.editHost({ 456 | oldHostConfig: oldConfig, 457 | newHostConfig: newConfig 458 | }); 459 | 460 | await fetchHosts(); 461 | 462 | setTimeout(() => fetchHosts(), 1000); 463 | 464 | editingTimeoutId.current = setTimeout(() => { 465 | setEditingHostId(null); 466 | editingTimeoutId.current = null; 467 | }, 3000); 468 | 469 | return result; 470 | } catch (err) { 471 | await new Promise(resolve => setTimeout(resolve, 500)); 472 | setEditingHostId(null); 473 | throw err; 474 | } 475 | }; 476 | 477 | 478 | useEffect(() => { 479 | 480 | if (isEditHostHidden && editingHostId !== null) { 481 | 482 | 483 | 484 | if (!editingTimeoutId.current) { 485 | 486 | editingTimeoutId.current = setTimeout(() => { 487 | setEditingHostId(null); 488 | editingTimeoutId.current = null; 489 | }, 2000); 490 | } 491 | } 492 | }, [isEditHostHidden, editingHostId]); 493 | 494 | const renderHostItem = (hostWrapper) => { 495 | const hostConfig = hostWrapper.config || {}; 496 | const isOwner = hostWrapper.isOwner === true; 497 | const isMenuActive = activeMenuButton === hostWrapper._id; 498 | const isPinningThisHost = isPinningInProgress && lastPinnedHost === hostWrapper._id; 499 | const isEditingThisHost = editingHostId === hostWrapper._id; 500 | const isThisHostBusy = isPinningThisHost || isEditingThisHost || deletingHostId === hostWrapper._id; 501 | 502 | const hostTags = hostWrapper.tags || hostWrapper.config?.tags || []; 503 | 504 | if (!hostConfig) { 505 | return null; 506 | } 507 | 508 | return ( 509 |
isOwner && handleDragStart(e, hostWrapper)} 514 | onDragEnd={() => setDraggedHost(null)} 515 | style={{ 516 | width: '100%', 517 | maxWidth: '100%', 518 | overflow: 'hidden', 519 | boxSizing: 'border-box' 520 | }} 521 | > 522 |
523 |
⋮⋮
524 |
525 |
526 |

527 | {hostConfig.name || hostConfig.ip} 528 |

529 | {isThisHostBusy && ( 530 | 539 | {deletingHostId === hostWrapper._id ? "Deleting..." : "Updating..."} 540 | 541 | )} 542 | {hostWrapper.isPinned && !isThisHostBusy && ( 543 | 552 | Pinned 553 | 554 | )} 555 | {!isOwner && ( 556 | 565 | Shared by {hostWrapper.createdBy?.username} 566 | 567 | )} 568 | {hostTags.map(tag => ( 569 | 580 | {tag} 581 | 582 | ))} 583 |
584 |

585 | {hostConfig.user ? `${hostConfig.user}@${hostConfig.ip}` : `${hostConfig.ip}:${hostConfig.port}`} 586 |

587 |
588 |
589 |
590 | 619 | { 623 | e.stopPropagation(); 624 | setSelectedHost(hostWrapper); 625 | setActiveMenuButton(hostWrapper._id); 626 | setIsMenuOpen(!isMenuOpen); 627 | anchorEl.current = e.currentTarget; 628 | }} 629 | disabled={isThisHostBusy} 630 | sx={{ 631 | backgroundColor: "#6e6e6e", 632 | "&:hover": { backgroundColor: "#0f0f0f" }, 633 | opacity: isThisHostBusy ? 0.5 : 1, 634 | cursor: isThisHostBusy ? "not-allowed" : "pointer", 635 | borderColor: "#3d3d3d", 636 | borderWidth: "2px", 637 | color: "#fff", 638 | fontSize: "20px", 639 | fontWeight: "bold" 640 | }} 641 | > 642 | ⋮ 643 | 644 |
645 |
646 | ); 647 | }; 648 | 649 | return ( 650 |
651 |
652 | setSearchTerm(e.target.value)} 656 | sx={{ 657 | flex: 1, 658 | backgroundColor: "#6e6e6e", 659 | color: "#fff", 660 | "&::placeholder": { color: "#ccc" }, 661 | }} 662 | /> 663 | 673 |
674 | 675 | {} 676 |
677 | {getAllTags(hosts).map(tag => ( 678 |
toggleTag(tag)} 681 | style={{ 682 | cursor: 'pointer', 683 | backgroundColor: selectedTags.has(tag) ? 'white' : '#2a2a2a', 684 | color: selectedTags.has(tag) ? 'black' : 'white', 685 | padding: '5px 10px', 686 | borderRadius: '4px', 687 | fontSize: '14px', 688 | fontWeight: 'normal', 689 | border: 'none', 690 | display: 'inline-flex', 691 | alignItems: 'center', 692 | height: '28px' 693 | }} 694 | > 695 | {tag} 696 |
697 | ))} 698 |
699 | 700 |
701 | {isLoading ? ( 702 |

Loading hosts...

703 | ) : filteredHosts.length > 0 ? ( 704 |
705 | {(() => { 706 | const { grouped, sortedFolders, noFolder } = groupHostsByFolder(filterHostsByTags(filteredHosts)); 707 | 708 | return ( 709 | <> 710 | {} 711 | {noFolder.length > 0 && ( 712 |
713 |
toggleFolder('no-folder')} 718 | onDragOver={(e) => handleDragOver(e, 'no-folder')} 719 | onDragLeave={handleDragLeave} 720 | onDrop={handleDropOnNoFolder} 721 | style={{ width: '100%', maxWidth: '100%', boxSizing: 'border-box' }} 722 | > 723 | 724 | ▼ 725 | 726 | No Folder 727 | 728 | ({noFolder.length}) 729 | 730 |
731 | 732 | {!collapsedFolders.has('no-folder') && ( 733 |
742 | {noFolder.map((host) => ( 743 |
744 | {renderHostItem(host)} 745 |
746 | ))} 747 |
748 | )} 749 |
750 | )} 751 | 752 | {} 753 | {sortedFolders.map((folderName) => ( 754 |
755 | {} 756 |
toggleFolder(folderName)} 762 | onDragOver={(e) => handleDragOver(e, folderName)} 763 | onDragLeave={handleDragLeave} 764 | onDrop={(e) => handleDrop(e, folderName)} 765 | style={{ width: '100%', maxWidth: '100%', boxSizing: 'border-box' }} 766 | > 767 | 768 | ▼ 769 | 770 | {folderName} 771 | 772 | ({grouped[folderName].length}) 773 | 774 |
775 | 776 | {!collapsedFolders.has(folderName) && ( 777 |
786 | {grouped[folderName].map((host) => ( 787 |
788 | {renderHostItem(host)} 789 |
790 | ))} 791 |
792 | )} 793 |
794 | ))} 795 | 796 | ); 797 | })()} 798 |
799 | ) : ( 800 |

No hosts found.

801 | )} 802 |
803 | 804 | {isMenuOpen && selectedHost && ( 805 | setIsMenuOpen(false)} 810 | sx={{ backdropFilter: 'blur(30px)' }} 811 | > 812 | {selectedHost.isOwner && ( 813 | { 815 | e.stopPropagation(); 816 | handleEditHost(selectedHost.config); 817 | setIsMenuOpen(false); 818 | }} 819 | > 820 | Edit 821 | 822 | )} 823 | { 825 | e.stopPropagation(); 826 | handlePinToggle(selectedHost); 827 | setIsMenuOpen(false); 828 | }} 829 | > 830 | {selectedHost.isPinned ? 'Unpin' : 'Pin'} 831 | 832 | {selectedHost.isOwner && ( 833 | { 835 | e.stopPropagation(); 836 | setSelectedHostForShare(selectedHost); 837 | setIsShareModalHidden(false); 838 | setIsMenuOpen(false); 839 | }} 840 | > 841 | Share 842 | 843 | )} 844 | { 846 | e.stopPropagation(); 847 | confirmDelete(selectedHost); 848 | }} 849 | sx={{ color: '#ef4444' }} 850 | > 851 | {selectedHost.isOwner ? 'Delete' : 'Remove'} 852 | 853 | 854 | )} 855 | 856 | {!isShareModalHidden && selectedHostForShare && ( 857 | 863 | )} 864 | 865 | {!isConfirmDeleteHidden && hostToDelete && ( 866 | handleDelete(null, hostToDelete)} 874 | onCancel={() => { 875 | setIsConfirmDeleteHidden(true); 876 | setHostToDelete(null); 877 | onModalClose(); 878 | }} 879 | /> 880 | )} 881 |
882 | ); 883 | } 884 | 885 | HostViewer.propTypes = { 886 | getHosts: PropTypes.func.isRequired, 887 | connectToHost: PropTypes.func.isRequired, 888 | setIsAddHostHidden: PropTypes.func.isRequired, 889 | deleteHost: PropTypes.func.isRequired, 890 | editHost: PropTypes.func.isRequired, 891 | openEditPanel: PropTypes.func.isRequired, 892 | shareHost: PropTypes.func.isRequired, 893 | onModalOpen: PropTypes.func.isRequired, 894 | onModalClose: PropTypes.func.isRequired, 895 | userRef: PropTypes.object, 896 | isMenuOpen: PropTypes.bool.isRequired, 897 | setIsMenuOpen: PropTypes.func.isRequired, 898 | isEditHostHidden: PropTypes.bool, 899 | isConfirmDeleteHidden: PropTypes.bool, 900 | setIsConfirmDeleteHidden: PropTypes.func, 901 | }; 902 | 903 | export default HostViewer; -------------------------------------------------------------------------------- /src/apps/hosts/rdp/RDPTerminal.jsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/src/apps/hosts/rdp/RDPTerminal.jsx -------------------------------------------------------------------------------- /src/apps/hosts/sftp/SFTPTerminal.jsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/src/apps/hosts/sftp/SFTPTerminal.jsx -------------------------------------------------------------------------------- /src/apps/hosts/vnc/VNCTerminal.jsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/src/apps/hosts/vnc/VNCTerminal.jsx -------------------------------------------------------------------------------- /src/apps/user/User.jsx: -------------------------------------------------------------------------------- 1 | import { useRef, forwardRef, useImperativeHandle, useEffect } from "react"; 2 | import io from "socket.io-client"; 3 | import PropTypes from "prop-types"; 4 | 5 | const SOCKET_URL = window.location.hostname === "localhost" 6 | ? "http://localhost:8081/database.io" 7 | : "/database.io"; 8 | 9 | const socket = io(SOCKET_URL, { 10 | path: "/database.io/socket.io", 11 | transports: ["websocket", "polling"], 12 | autoConnect: false, 13 | }); 14 | 15 | export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSuccess, onFailure }, ref) => { 16 | const socketRef = useRef(socket); 17 | const currentUser = useRef(null); 18 | 19 | useEffect(() => { 20 | socketRef.current.connect(); 21 | return () => socketRef.current.disconnect(); 22 | }, []); 23 | 24 | useEffect(() => { 25 | const verifySession = async () => { 26 | const storedSession = localStorage.getItem("sessionToken"); 27 | if (!storedSession || storedSession === "undefined") return; 28 | 29 | try { 30 | const response = await new Promise((resolve) => { 31 | socketRef.current.emit("verifySession", { sessionToken: storedSession }, resolve); 32 | }); 33 | 34 | if (response?.success) { 35 | currentUser.current = { 36 | id: response.user.id, 37 | username: response.user.username, 38 | sessionToken: storedSession, 39 | isAdmin: response.user.isAdmin || false, 40 | }; 41 | onLoginSuccess(response.user); 42 | } else { 43 | localStorage.removeItem("sessionToken"); 44 | onFailure("Session expired"); 45 | } 46 | } catch (error) { 47 | onFailure(error.message); 48 | } 49 | }; 50 | 51 | verifySession(); 52 | }, []); 53 | 54 | const createUser = async (userConfig) => { 55 | try { 56 | const accountCreationStatus = await checkAccountCreationStatus(); 57 | if (!accountCreationStatus.allowed && !accountCreationStatus.isFirstUser) { 58 | throw new Error("Account creation has been disabled by an administrator"); 59 | } 60 | 61 | const response = await new Promise((resolve) => { 62 | const isFirstUser = accountCreationStatus.isFirstUser; 63 | socketRef.current.emit("createUser", { ...userConfig, isAdmin: isFirstUser }, resolve); 64 | }); 65 | 66 | if (response?.user?.sessionToken) { 67 | currentUser.current = { 68 | id: response.user.id, 69 | username: response.user.username, 70 | sessionToken: response.user.sessionToken, 71 | isAdmin: response.user.isAdmin || false, 72 | }; 73 | localStorage.setItem("sessionToken", response.user.sessionToken); 74 | onCreateSuccess(response.user); 75 | } else { 76 | throw new Error(response?.error || "User creation failed"); 77 | } 78 | } catch (error) { 79 | onFailure(error.message); 80 | } 81 | }; 82 | 83 | const loginUser = async ({ username, password, sessionToken }) => { 84 | try { 85 | const response = await new Promise((resolve) => { 86 | const credentials = sessionToken ? { sessionToken } : { username, password }; 87 | socketRef.current.emit("loginUser", credentials, resolve); 88 | }); 89 | 90 | if (response?.success) { 91 | currentUser.current = { 92 | id: response.user.id, 93 | username: response.user.username, 94 | sessionToken: response.user.sessionToken, 95 | isAdmin: response.user.isAdmin || false, 96 | }; 97 | localStorage.setItem("sessionToken", response.user.sessionToken); 98 | onLoginSuccess(response.user); 99 | } else { 100 | throw new Error(response?.error || "Login failed"); 101 | } 102 | } catch (error) { 103 | onFailure(error.message); 104 | } 105 | }; 106 | 107 | const loginAsGuest = async () => { 108 | try { 109 | const response = await new Promise((resolve) => { 110 | socketRef.current.emit("loginAsGuest", resolve); 111 | }); 112 | 113 | if (response?.success) { 114 | currentUser.current = { 115 | id: response.user.id, 116 | username: response.user.username, 117 | sessionToken: response.user.sessionToken, 118 | isAdmin: false, 119 | }; 120 | localStorage.setItem("sessionToken", response.user.sessionToken); 121 | onLoginSuccess(response.user); 122 | } else { 123 | throw new Error(response?.error || "Guest login failed"); 124 | } 125 | } catch (error) { 126 | onFailure(error.message); 127 | } 128 | } 129 | 130 | const logoutUser = () => { 131 | localStorage.removeItem("sessionToken"); 132 | currentUser.current = null; 133 | onLoginSuccess(null); 134 | }; 135 | 136 | const deleteUser = async () => { 137 | if (!currentUser.current) return onFailure("No user logged in"); 138 | 139 | try { 140 | const response = await new Promise((resolve) => { 141 | socketRef.current.emit("deleteUser", { 142 | userId: currentUser.current.id, 143 | sessionToken: currentUser.current.sessionToken, 144 | }, resolve); 145 | }); 146 | 147 | if (response?.success) { 148 | logoutUser(); 149 | onDeleteSuccess(response); 150 | } else { 151 | throw new Error(response?.error || "User deletion failed"); 152 | } 153 | } catch (error) { 154 | onFailure(error.message); 155 | } 156 | }; 157 | 158 | const checkAccountCreationStatus = async () => { 159 | try { 160 | const response = await new Promise((resolve) => { 161 | socketRef.current.emit("checkAccountCreationStatus", resolve); 162 | }); 163 | 164 | return { 165 | allowed: response?.allowed !== false, 166 | isFirstUser: response?.isFirstUser || false 167 | }; 168 | } catch (error) { 169 | return { allowed: true, isFirstUser: false }; 170 | } 171 | }; 172 | 173 | const toggleAccountCreation = async (enabled) => { 174 | if (!currentUser.current?.isAdmin) return onFailure("Not authorized"); 175 | 176 | try { 177 | const response = await new Promise((resolve) => { 178 | socketRef.current.emit("toggleAccountCreation", { 179 | userId: currentUser.current.id, 180 | sessionToken: currentUser.current.sessionToken, 181 | enabled 182 | }, resolve); 183 | }); 184 | 185 | if (!response?.success) { 186 | throw new Error(response?.error || "Failed to update account creation settings"); 187 | } 188 | 189 | return response.enabled; 190 | } catch (error) { 191 | onFailure(error.message); 192 | return null; 193 | } 194 | }; 195 | 196 | const addAdminUser = async (username) => { 197 | if (!currentUser.current?.isAdmin) return onFailure("Not authorized"); 198 | 199 | try { 200 | const response = await new Promise((resolve) => { 201 | socketRef.current.emit("addAdminUser", { 202 | userId: currentUser.current.id, 203 | sessionToken: currentUser.current.sessionToken, 204 | targetUsername: username 205 | }, resolve); 206 | }); 207 | 208 | if (!response?.success) { 209 | const errorMsg = response?.error || "Failed to add admin user"; 210 | throw new Error(errorMsg); 211 | } 212 | 213 | return true; 214 | } catch (error) { 215 | onFailure(error.message); 216 | return false; 217 | } 218 | }; 219 | 220 | const getAllAdmins = async () => { 221 | if (!currentUser.current?.isAdmin) return []; 222 | 223 | try { 224 | const response = await new Promise((resolve) => { 225 | socketRef.current.emit("getAllAdmins", { 226 | userId: currentUser.current.id, 227 | sessionToken: currentUser.current.sessionToken, 228 | }, resolve); 229 | }); 230 | 231 | if (response?.success) { 232 | return response.admins || []; 233 | } else { 234 | throw new Error(response?.error || "Failed to fetch admins"); 235 | } 236 | } catch (error) { 237 | onFailure(error.message); 238 | return []; 239 | } 240 | }; 241 | 242 | const saveHost = async (hostConfig) => { 243 | if (!currentUser.current) return onFailure("Not authenticated"); 244 | 245 | try { 246 | if (!hostConfig || !hostConfig.hostConfig) { 247 | return onFailure("Invalid host configuration"); 248 | } 249 | 250 | if (!hostConfig.hostConfig.ip || !hostConfig.hostConfig.user) { 251 | return onFailure("Host must have IP and username"); 252 | } 253 | 254 | if (!hostConfig.hostConfig.name || hostConfig.hostConfig.name.trim() === '') { 255 | hostConfig.hostConfig.name = hostConfig.hostConfig.ip; 256 | } 257 | 258 | const existingHosts = await getAllHosts(); 259 | 260 | const duplicateNameHost = existingHosts.find(host => 261 | host && host.config && host.config.name && 262 | typeof host.config.name === 'string' && 263 | typeof hostConfig.hostConfig.name === 'string' && 264 | host.config.name.toLowerCase() === hostConfig.hostConfig.name.toLowerCase() 265 | ); 266 | 267 | if (duplicateNameHost) { 268 | return onFailure("A host with this name already exists. Please choose a different name."); 269 | } 270 | 271 | if (!hostConfig.hostConfig.terminalConfig) { 272 | hostConfig.hostConfig.terminalConfig = { 273 | theme: 'dark', 274 | cursorStyle: 'block', 275 | fontFamily: 'ubuntuMono', 276 | fontSize: 14, 277 | fontWeight: 'normal', 278 | lineHeight: 1, 279 | letterSpacing: 0, 280 | cursorBlink: true, 281 | sshAlgorithm: 'default' 282 | }; 283 | } 284 | 285 | const response = await new Promise((resolve) => { 286 | socketRef.current.emit("saveHostConfig", { 287 | userId: currentUser.current.id, 288 | sessionToken: currentUser.current.sessionToken, 289 | hostConfig: hostConfig.hostConfig 290 | }, resolve); 291 | }); 292 | 293 | if (!response?.success) { 294 | throw new Error(response?.error || "Failed to save host"); 295 | } 296 | } catch (error) { 297 | onFailure(error.message); 298 | } 299 | }; 300 | 301 | const getAllHosts = async () => { 302 | if (!currentUser.current) return []; 303 | 304 | try { 305 | const response = await new Promise((resolve) => { 306 | socketRef.current.emit("getHosts", { 307 | userId: currentUser.current.id, 308 | sessionToken: currentUser.current.sessionToken, 309 | }, resolve); 310 | }); 311 | 312 | if (response?.success && Array.isArray(response.hosts)) { 313 | return response.hosts.map(host => { 314 | if (!host) return null; 315 | 316 | return { 317 | ...host, 318 | config: host.config ? { 319 | name: host.config.name || host.name || '', 320 | folder: host.config.folder || host.folder || '', 321 | ip: host.config.ip || host.ip || '', 322 | user: host.config.user || host.user || '', 323 | port: host.config.port || host.port || '22', 324 | password: host.config.password || host.password || '', 325 | sshKey: host.config.sshKey || host.sshKey || '', 326 | keyType: host.config.keyType || host.keyType || '', 327 | isPinned: host.isPinned || false, 328 | tags: host.config.tags || host.tags || [], 329 | terminalConfig: host.config.terminalConfig || { 330 | theme: 'dark', 331 | cursorStyle: 'block', 332 | fontFamily: 'ubuntuMono', 333 | fontSize: 14, 334 | fontWeight: 'normal', 335 | lineHeight: 1, 336 | letterSpacing: 0, 337 | cursorBlink: true, 338 | sshAlgorithm: 'default' 339 | } 340 | } : { 341 | name: host.name || '', 342 | folder: host.folder || '', 343 | ip: host.ip || '', 344 | user: host.user || '', 345 | port: host.port || '22', 346 | password: host.password || '', 347 | sshKey: host.sshKey || '', 348 | keyType: host.keyType || '', 349 | isPinned: host.isPinned || false, 350 | tags: host.tags || [], 351 | terminalConfig: host.terminalConfig || { 352 | theme: 'dark', 353 | cursorStyle: 'block', 354 | fontFamily: 'ubuntuMono', 355 | fontSize: 14, 356 | fontWeight: 'normal', 357 | lineHeight: 1, 358 | letterSpacing: 0, 359 | cursorBlink: true, 360 | sshAlgorithm: 'default' 361 | } 362 | } 363 | }; 364 | }).filter(host => host && host.config && host.config.ip && host.config.user); 365 | } else { 366 | return []; 367 | } 368 | } catch (error) { 369 | onFailure(error.message); 370 | return []; 371 | } 372 | }; 373 | 374 | const deleteHost = async ({ hostId }) => { 375 | if (!currentUser.current) return onFailure("Not authenticated"); 376 | 377 | try { 378 | const response = await new Promise((resolve) => { 379 | socketRef.current.emit("deleteHost", { 380 | userId: currentUser.current.id, 381 | sessionToken: currentUser.current.sessionToken, 382 | hostId: hostId, 383 | }, resolve); 384 | }); 385 | 386 | if (!response?.success) { 387 | throw new Error(response?.error || "Failed to delete host"); 388 | } 389 | } catch (error) { 390 | onFailure(error.message); 391 | } 392 | }; 393 | 394 | const editHost = async ({ oldHostConfig, newHostConfig }) => { 395 | if (!currentUser.current) return onFailure("Not authenticated"); 396 | 397 | try { 398 | 399 | if (!oldHostConfig || !newHostConfig) { 400 | return onFailure("Invalid host configuration"); 401 | } 402 | 403 | if (!newHostConfig.ip || !newHostConfig.user) { 404 | return onFailure("Host must have IP and username"); 405 | } 406 | 407 | if (!oldHostConfig._id && !oldHostConfig.id) { 408 | return onFailure("Cannot identify host to edit: missing ID"); 409 | } 410 | 411 | const hostId = oldHostConfig._id || oldHostConfig.id; 412 | oldHostConfig._id = hostId; 413 | oldHostConfig.id = hostId; 414 | newHostConfig._id = hostId; 415 | newHostConfig.id = hostId; 416 | 417 | if (!newHostConfig.name || newHostConfig.name.trim() === '') { 418 | newHostConfig.name = newHostConfig.ip; 419 | } 420 | 421 | const isNameUnchanged = 422 | oldHostConfig.name && 423 | newHostConfig.name && 424 | oldHostConfig.name.toLowerCase() === newHostConfig.name.toLowerCase(); 425 | 426 | if (!isNameUnchanged) { 427 | const existingHosts = await getAllHosts(); 428 | 429 | const duplicateNameHost = existingHosts.find(host => 430 | host && 431 | host.config && 432 | host.config.name && 433 | typeof host.config.name === 'string' && 434 | typeof newHostConfig.name === 'string' && 435 | host.config.name.toLowerCase() === newHostConfig.name.toLowerCase() && 436 | host._id !== hostId 437 | ); 438 | 439 | if (duplicateNameHost) { 440 | return onFailure(`Host with name "${newHostConfig.name}" already exists. Please choose a different name.`); 441 | } 442 | } 443 | 444 | if (!newHostConfig.terminalConfig) { 445 | newHostConfig.terminalConfig = oldHostConfig.terminalConfig || { 446 | theme: 'dark', 447 | cursorStyle: 'block', 448 | fontFamily: 'ubuntuMono', 449 | fontSize: 14, 450 | fontWeight: 'normal', 451 | lineHeight: 1, 452 | letterSpacing: 0, 453 | cursorBlink: true, 454 | sshAlgorithm: 'default' 455 | }; 456 | } 457 | 458 | const response = await new Promise((resolve) => { 459 | socketRef.current.emit("editHost", { 460 | userId: currentUser.current.id, 461 | sessionToken: currentUser.current.sessionToken, 462 | oldHostConfig, 463 | newHostConfig, 464 | }, resolve); 465 | }); 466 | 467 | if (!response?.success) { 468 | throw new Error(response?.error || "Failed to edit host"); 469 | } 470 | 471 | return response; 472 | } catch (error) { 473 | onFailure(error.message); 474 | return { success: false, error: error.message }; 475 | } 476 | }; 477 | 478 | const shareHost = async (hostId, targetUsername) => { 479 | if (!currentUser.current) return onFailure("Not authenticated"); 480 | 481 | try { 482 | const response = await new Promise((resolve) => { 483 | socketRef.current.emit("shareHost", { 484 | userId: currentUser.current.id, 485 | sessionToken: currentUser.current.sessionToken, 486 | hostId, 487 | targetUsername, 488 | }, resolve); 489 | }); 490 | 491 | if (!response?.success) { 492 | throw new Error(response?.error || "Failed to share host"); 493 | } 494 | } catch (error) { 495 | onFailure(error.message); 496 | } 497 | }; 498 | 499 | const removeShare = async (hostId) => { 500 | if (!currentUser.current) return onFailure("Not authenticated"); 501 | 502 | try { 503 | const response = await new Promise((resolve) => { 504 | socketRef.current.emit("removeShare", { 505 | userId: currentUser.current.id, 506 | sessionToken: currentUser.current.sessionToken, 507 | hostId, 508 | }, resolve); 509 | }); 510 | 511 | if (!response?.success) { 512 | throw new Error(response?.error || "Failed to remove share"); 513 | } 514 | } catch (error) { 515 | onFailure(error.message); 516 | } 517 | }; 518 | 519 | const saveSnippet = async (snippet) => { 520 | if (!currentUser.current) return onFailure("Not authenticated"); 521 | 522 | try { 523 | const response = await new Promise((resolve) => { 524 | socketRef.current.emit("saveSnippet", { 525 | userId: currentUser.current.id, 526 | sessionToken: currentUser.current.sessionToken, 527 | snippet 528 | }, resolve); 529 | }); 530 | 531 | if (!response?.success) { 532 | throw new Error(response?.error || "Failed to save snippet"); 533 | } 534 | 535 | return true; 536 | } catch (error) { 537 | onFailure(error.message); 538 | return false; 539 | } 540 | }; 541 | 542 | const getAllSnippets = async () => { 543 | if (!currentUser.current) return []; 544 | 545 | try { 546 | const response = await new Promise((resolve) => { 547 | socketRef.current.emit("getSnippets", { 548 | userId: currentUser.current.id, 549 | sessionToken: currentUser.current.sessionToken, 550 | }, resolve); 551 | }); 552 | 553 | if (response?.success) { 554 | return response.snippets.map(snippet => ({ 555 | ...snippet, 556 | isPinned: snippet.isPinned || false, 557 | tags: snippet.tags || [] 558 | })); 559 | } else { 560 | throw new Error(response?.error || "Failed to fetch snippets"); 561 | } 562 | } catch (error) { 563 | onFailure(error.message); 564 | return []; 565 | } 566 | }; 567 | 568 | const deleteSnippet = async ({ snippetId }) => { 569 | if (!currentUser.current) return onFailure("Not authenticated"); 570 | 571 | try { 572 | const response = await new Promise((resolve) => { 573 | socketRef.current.emit("deleteSnippet", { 574 | userId: currentUser.current.id, 575 | sessionToken: currentUser.current.sessionToken, 576 | snippetId, 577 | }, resolve); 578 | }); 579 | 580 | if (!response?.success) { 581 | throw new Error(response?.error || "Failed to delete snippet"); 582 | } 583 | 584 | return true; 585 | } catch (error) { 586 | onFailure(error.message); 587 | return false; 588 | } 589 | }; 590 | 591 | const editSnippet = async ({ oldSnippet, newSnippet }) => { 592 | if (!currentUser.current) return onFailure("Not authenticated"); 593 | 594 | try { 595 | const response = await new Promise((resolve) => { 596 | socketRef.current.emit("editSnippet", { 597 | userId: currentUser.current.id, 598 | sessionToken: currentUser.current.sessionToken, 599 | oldSnippet, 600 | newSnippet, 601 | }, resolve); 602 | }); 603 | 604 | if (!response?.success) { 605 | throw new Error(response?.error || "Failed to edit snippet"); 606 | } 607 | 608 | return true; 609 | } catch (error) { 610 | onFailure(error.message); 611 | return false; 612 | } 613 | }; 614 | 615 | const shareSnippet = async (snippetId, targetUsername) => { 616 | if (!currentUser.current) return onFailure("Not authenticated"); 617 | 618 | try { 619 | const response = await new Promise((resolve) => { 620 | socketRef.current.emit("shareSnippet", { 621 | userId: currentUser.current.id, 622 | sessionToken: currentUser.current.sessionToken, 623 | snippetId, 624 | targetUsername, 625 | }, resolve); 626 | }); 627 | 628 | if (!response?.success) { 629 | throw new Error(response?.error || "Failed to share snippet"); 630 | } 631 | 632 | return true; 633 | } catch (error) { 634 | onFailure(error.message); 635 | return false; 636 | } 637 | }; 638 | 639 | const removeSnippetShare = async (snippetId) => { 640 | if (!currentUser.current) return onFailure("Not authenticated"); 641 | 642 | try { 643 | const response = await new Promise((resolve) => { 644 | socketRef.current.emit("removeSnippetShare", { 645 | userId: currentUser.current.id, 646 | sessionToken: currentUser.current.sessionToken, 647 | snippetId, 648 | }, resolve); 649 | }); 650 | 651 | if (!response?.success) { 652 | throw new Error(response?.error || "Failed to remove snippet share"); 653 | } 654 | 655 | return true; 656 | } catch (error) { 657 | onFailure(error.message); 658 | return false; 659 | } 660 | }; 661 | 662 | useImperativeHandle(ref, () => ({ 663 | createUser, 664 | loginUser, 665 | loginAsGuest, 666 | logoutUser, 667 | deleteUser, 668 | saveHost, 669 | getAllHosts, 670 | deleteHost, 671 | shareHost, 672 | editHost, 673 | removeShare, 674 | saveSnippet, 675 | getAllSnippets, 676 | deleteSnippet, 677 | editSnippet, 678 | shareSnippet, 679 | removeSnippetShare, 680 | getUser: () => currentUser.current, 681 | getSocketRef: () => socketRef.current, 682 | checkAccountCreationStatus, 683 | toggleAccountCreation, 684 | addAdminUser, 685 | getAllAdmins, 686 | isAdmin: () => currentUser.current?.isAdmin || false, 687 | })); 688 | 689 | return null; 690 | }); 691 | 692 | User.displayName = "User"; 693 | 694 | User.propTypes = { 695 | onLoginSuccess: PropTypes.func.isRequired, 696 | onCreateSuccess: PropTypes.func.isRequired, 697 | onDeleteSuccess: PropTypes.func.isRequired, 698 | onFailure: PropTypes.func.isRequired, 699 | }; -------------------------------------------------------------------------------- /src/backend/rdp.cjs: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const socketIo = require("socket.io"); 3 | 4 | const server = http.createServer(); 5 | const io = socketIo(server, { 6 | path: "/rdp.io/socket.io", 7 | cors: { 8 | origin: "*", 9 | methods: ["GET", "POST"], 10 | credentials: true 11 | }, 12 | allowEIO3: true, 13 | pingInterval: 2000, 14 | pingTimeout: 10000, 15 | maxHttpBufferSize: 1e7, 16 | connectTimeout: 15000, 17 | transports: ['websocket', 'polling'], 18 | }); 19 | 20 | const logger = { 21 | info: (...args) => console.log(`🖥️ | 🔧 [${new Date().toISOString()}] INFO:`, ...args), 22 | error: (...args) => console.error(`🖥️ | ❌ [${new Date().toISOString()}] ERROR:`, ...args), 23 | warn: (...args) => console.warn(`🖥️ | ⚠️ [${new Date().toISOString()}] WARN:`, ...args), 24 | debug: (...args) => console.debug(`🖥️ | 🔍 [${new Date().toISOString()}] DEBUG:`, ...args) 25 | }; 26 | 27 | server.listen(8083, '0.0.0.0', () => { 28 | logger.info("Server is running on port 8083"); 29 | }); -------------------------------------------------------------------------------- /src/backend/sftp.cjs: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const socketIo = require("socket.io"); 3 | 4 | const server = http.createServer(); 5 | const io = socketIo(server, { 6 | path: "/sftp.io/socket.io", 7 | cors: { 8 | origin: "*", 9 | methods: ["GET", "POST"], 10 | credentials: true 11 | }, 12 | allowEIO3: true, 13 | pingInterval: 2000, 14 | pingTimeout: 10000, 15 | maxHttpBufferSize: 1e7, 16 | connectTimeout: 15000, 17 | transports: ['websocket', 'polling'], 18 | }); 19 | 20 | const logger = { 21 | info: (...args) => console.log(`📁 | 🔧 [${new Date().toISOString()}] INFO:`, ...args), 22 | error: (...args) => console.error(`📁 | ❌ [${new Date().toISOString()}] ERROR:`, ...args), 23 | warn: (...args) => console.warn(`📁 | ⚠️ [${new Date().toISOString()}] WARN:`, ...args), 24 | debug: (...args) => console.debug(`📁 | 🔍 [${new Date().toISOString()}] DEBUG:`, ...args) 25 | }; 26 | 27 | server.listen(8085, '0.0.0.0', () => { 28 | logger.info("Server is running on port 8085"); 29 | }); -------------------------------------------------------------------------------- /src/backend/ssh.cjs: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const socketIo = require("socket.io"); 3 | const SSHClient = require("ssh2").Client; 4 | 5 | const server = http.createServer(); 6 | const io = socketIo(server, { 7 | path: "/ssh.io/socket.io", 8 | cors: { 9 | origin: "*", 10 | methods: ["GET", "POST"], 11 | credentials: true 12 | }, 13 | allowEIO3: true, 14 | pingInterval: 2000, 15 | pingTimeout: 10000, 16 | maxHttpBufferSize: 1e7, 17 | connectTimeout: 15000, 18 | transports: ['websocket', 'polling'], 19 | }); 20 | 21 | const logger = { 22 | info: (...args) => console.log(`⌨️ | 🔧 [${new Date().toISOString()}] INFO:`, ...args), 23 | error: (...args) => console.error(`⌨️ | ❌ [${new Date().toISOString()}] ERROR:`, ...args), 24 | warn: (...args) => console.warn(`⌨️ | ⚠️ [${new Date().toISOString()}] WARN:`, ...args), 25 | debug: (...args) => console.debug(`⌨️ | 🔍 [${new Date().toISOString()}] DEBUG:`, ...args) 26 | }; 27 | 28 | io.on("connection", (socket) => { 29 | let stream = null; 30 | let conn = null; 31 | let pingTimer = null; 32 | 33 | function setupPingInterval() { 34 | if (pingTimer) { 35 | clearInterval(pingTimer); 36 | } 37 | 38 | pingTimer = setInterval(() => { 39 | if (socket && socket.connected) { 40 | socket.emit("ping"); 41 | 42 | if (conn && conn.ping) { 43 | try { 44 | conn.ping(); 45 | } catch (err) { 46 | } 47 | } 48 | } else { 49 | clearInterval(pingTimer); 50 | } 51 | }, 3000); 52 | } 53 | 54 | setupPingInterval(); 55 | 56 | socket.on("connectToHost", (cols, rows, hostConfig) => { 57 | if (!hostConfig || !hostConfig.ip || !hostConfig.user || !hostConfig.port) { 58 | logger.error("Invalid hostConfig received"); 59 | socket.emit("error", "Missing required connection details (IP, user, or port)"); 60 | return; 61 | } 62 | 63 | if (!hostConfig.password && !hostConfig.sshKey) { 64 | logger.error("No authentication provided"); 65 | socket.emit("error", "Authentication required"); 66 | return; 67 | } 68 | 69 | const { ip, port, user, password, sshKey } = hostConfig; 70 | const sshAlgorithm = hostConfig.sshAlgorithm || 'default'; 71 | 72 | if (conn) { 73 | try { 74 | const currentConn = conn; 75 | conn = null; 76 | stream = null; 77 | currentConn.end(); 78 | } catch (err) { 79 | } 80 | } 81 | 82 | conn = new SSHClient(); 83 | conn 84 | .on("ready", function () { 85 | conn.shell({ 86 | term: "xterm-256color", 87 | modes: { 88 | ECHO: 1, 89 | ECHOCTL: 0, 90 | ICANON: 1, 91 | TTY_OP_OSWRAP: 1 92 | }, 93 | keepaliveInterval: 30000 94 | }, function (err, newStream) { 95 | if (err) { 96 | logger.error("Shell error:", err.message); 97 | socket.emit("error", err.message); 98 | return; 99 | } 100 | stream = newStream; 101 | 102 | const currentCols = cols; 103 | const currentRows = rows; 104 | 105 | stream.setWindow(currentRows, currentCols, currentRows, currentCols); 106 | 107 | stream.once('ready', () => { 108 | conn.exec(`stty cols ${currentCols} rows ${currentRows} -icanon -echo && stty onlcr && stty -opost`, { pty: false }, (err, execStream) => { 109 | if (err) logger.error("Failed to set terminal properties:", err); 110 | }); 111 | }); 112 | 113 | let dataBuffer = []; 114 | let isProcessingBuffer = false; 115 | 116 | const processBuffer = () => { 117 | if (dataBuffer.length === 0 || isProcessingBuffer) return; 118 | 119 | isProcessingBuffer = true; 120 | 121 | const currentBuffer = [...dataBuffer]; 122 | dataBuffer = []; 123 | 124 | let combinedData; 125 | if (currentBuffer.length > 1) { 126 | const totalLength = currentBuffer.reduce((acc, chunk) => acc + chunk.length, 0); 127 | combinedData = Buffer.alloc(totalLength); 128 | 129 | let offset = 0; 130 | for (const chunk of currentBuffer) { 131 | chunk.copy(combinedData, offset); 132 | offset += chunk.length; 133 | } 134 | } else { 135 | combinedData = currentBuffer[0]; 136 | } 137 | 138 | socket.emit("data", combinedData); 139 | 140 | isProcessingBuffer = false; 141 | if (dataBuffer.length > 0) { 142 | setImmediate(processBuffer); 143 | } 144 | }; 145 | 146 | stream.on("data", function (data) { 147 | dataBuffer.push(data); 148 | 149 | if (!isProcessingBuffer) { 150 | setImmediate(processBuffer); 151 | } 152 | }); 153 | 154 | stream.on("error", function(err) { 155 | logger.error("SSH stream error:", err.message); 156 | socket.emit("error", "SSH connection error: " + err.message); 157 | }); 158 | 159 | stream.on("close", function () { 160 | if (stream) { 161 | try { 162 | stream.end(); 163 | } catch (err) { 164 | logger.error("Error ending stream:", err.message); 165 | } 166 | } 167 | 168 | if (conn) { 169 | try { 170 | conn.end(); 171 | } catch (err) { 172 | logger.error("Error ending connection:", err.message); 173 | } 174 | conn = null; 175 | } 176 | 177 | stream = null; 178 | }); 179 | 180 | let outgoingBuffer = []; 181 | let isProcessingOutgoing = false; 182 | 183 | const processOutgoingBuffer = () => { 184 | if (outgoingBuffer.length === 0 || isProcessingOutgoing || !stream) return; 185 | 186 | isProcessingOutgoing = true; 187 | 188 | const currentBuffer = outgoingBuffer.join(''); 189 | outgoingBuffer = []; 190 | 191 | stream.write(currentBuffer); 192 | 193 | isProcessingOutgoing = false; 194 | if (outgoingBuffer.length > 0) { 195 | setImmediate(processOutgoingBuffer); 196 | } 197 | }; 198 | 199 | socket.on("data", function (data) { 200 | outgoingBuffer.push(data); 201 | 202 | if (!isProcessingOutgoing) { 203 | setImmediate(processOutgoingBuffer); 204 | } 205 | }); 206 | 207 | socket.on("resize", function (data) { 208 | if (stream && stream.setWindow) { 209 | stream.setWindow(data.rows, data.cols, data.rows, data.cols); 210 | 211 | if (conn) { 212 | conn.exec(`stty cols ${data.cols} rows ${data.rows} -icanon -echo`, { pty: false }, (err, execStream) => { 213 | }); 214 | } 215 | 216 | socket.emit("resize", { cols: data.cols, rows: data.rows }); 217 | } 218 | }); 219 | 220 | socket.emit("resize", { cols, rows }); 221 | }); 222 | }) 223 | .on("close", function () { 224 | if (stream) { 225 | try { 226 | stream.end(); 227 | } catch (err) { 228 | } 229 | } 230 | 231 | conn = null; 232 | stream = null; 233 | }) 234 | .on("error", function (err) { 235 | logger.error("SSH error:", err.message); 236 | socket.emit("error", err.message); 237 | 238 | const currentConn = conn; 239 | const currentStream = stream; 240 | 241 | conn = null; 242 | stream = null; 243 | 244 | if (currentStream) { 245 | try { 246 | currentStream.end(); 247 | } catch (closeErr) { 248 | } 249 | } 250 | 251 | if (currentConn) { 252 | try { 253 | currentConn.end(); 254 | } catch (closeErr) { 255 | } 256 | } 257 | }) 258 | .on("ping", function () { 259 | socket.emit("ping"); 260 | }) 261 | .connect({ 262 | host: ip, 263 | port: port, 264 | username: user, 265 | password: password || undefined, 266 | privateKey: sshKey ? Buffer.from(sshKey) : undefined, 267 | algorithms: getAlgorithms(sshAlgorithm), 268 | keepaliveInterval: 5000, 269 | keepaliveCountMax: 10, 270 | readyTimeout: 10000, 271 | tcpKeepAlive: true, 272 | }); 273 | }); 274 | 275 | function getAlgorithms(algorithmPreference) { 276 | switch (algorithmPreference) { 277 | case 'legacy': 278 | return { 279 | kex: ['diffie-hellman-group1-sha1', 'diffie-hellman-group14-sha1'], 280 | serverHostKey: ['ssh-rsa', 'ssh-dss'] 281 | }; 282 | case 'secure': 283 | return { 284 | kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'diffie-hellman-group-exchange-sha256'], 285 | serverHostKey: ['ssh-ed25519', 'rsa-sha2-512', 'rsa-sha2-256'] 286 | }; 287 | case 'default': 288 | default: 289 | return { 290 | kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256'], 291 | serverHostKey: ['ssh-ed25519', 'ecdsa-sha2-nistp256'] 292 | }; 293 | } 294 | } 295 | 296 | socket.on("disconnect", () => { 297 | const currentStream = stream; 298 | const currentConn = conn; 299 | 300 | if (pingTimer) { 301 | clearInterval(pingTimer); 302 | pingTimer = null; 303 | } 304 | 305 | stream = null; 306 | conn = null; 307 | 308 | if (currentStream) { 309 | try { 310 | currentStream.write("exit\r"); 311 | } catch (err) { 312 | } 313 | } 314 | 315 | if (currentConn) { 316 | try { 317 | currentConn.end(); 318 | } catch (err) { 319 | } 320 | } 321 | }); 322 | }); 323 | 324 | server.listen(8082, '0.0.0.0', () => { 325 | logger.info("Server is running on port 8082"); 326 | }); -------------------------------------------------------------------------------- /src/backend/starter.cjs: -------------------------------------------------------------------------------- 1 | const database = require('./database.cjs'); 2 | const sshServer = require('./ssh.cjs'); 3 | //const rdpServer = require('./rdp.cjs'); 4 | //const vncServer = require('./vnc.cjs'); 5 | //const sftpServer = require('./sftp.cjs'); 6 | 7 | const logger = { 8 | info: (...args) => console.log(`🚀 | 🔧 [${new Date().toISOString()}] INFO:`, ...args), 9 | error: (...args) => console.error(`🚀 | ❌ [${new Date().toISOString()}] ERROR:`, ...args), 10 | warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args), 11 | debug: (...args) => console.debug(`🚀 | 🔍 [${new Date().toISOString()}] DEBUG:`, ...args) 12 | }; 13 | 14 | (async () => { 15 | try { 16 | logger.info("Starting all backend servers..."); 17 | 18 | logger.info("All servers started successfully"); 19 | 20 | process.on('SIGINT', () => { 21 | logger.info("Shutting down servers..."); 22 | process.exit(0); 23 | }); 24 | } catch (error) { 25 | logger.error("Failed to start servers:", error); 26 | process.exit(1); 27 | } 28 | })(); 29 | -------------------------------------------------------------------------------- /src/backend/vnc.cjs: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const socketIo = require("socket.io"); 3 | 4 | const server = http.createServer(); 5 | const io = socketIo(server, { 6 | path: "/vnc.io/socket.io", 7 | cors: { 8 | origin: "*", 9 | methods: ["GET", "POST"], 10 | credentials: true 11 | }, 12 | allowEIO3: true, 13 | pingInterval: 2000, 14 | pingTimeout: 10000, 15 | maxHttpBufferSize: 1e7, 16 | connectTimeout: 15000, 17 | transports: ['websocket', 'polling'], 18 | }); 19 | 20 | const logger = { 21 | info: (...args) => console.log(`🖱️ | 🔧 [${new Date().toISOString()}] INFO:`, ...args), 22 | error: (...args) => console.error(`🖱️ | ❌ [${new Date().toISOString()}] ERROR:`, ...args), 23 | warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args), 24 | debug: (...args) => console.debug(`🖱️ | 🔍 [${new Date().toISOString()}] DEBUG:`, ...args) 25 | }; 26 | 27 | server.listen(8084, '0.0.0.0', () => { 28 | logger.info("Server is running on port 8084"); 29 | }); -------------------------------------------------------------------------------- /src/images/host_viewer_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/src/images/host_viewer_icon.png -------------------------------------------------------------------------------- /src/images/launchpad_rocket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/src/images/launchpad_rocket.png -------------------------------------------------------------------------------- /src/images/profile_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/src/images/profile_icon.png -------------------------------------------------------------------------------- /src/images/snippets_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/src/images/snippets_icon.png -------------------------------------------------------------------------------- /src/images/termix_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukeGus/Termix/cb36a3cc8c8cd1b5ad95fc89e003d819fa29ff1b/src/images/termix_icon.png -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import '@fontsource/inter'; 2 | @import "tailwindcss"; 3 | 4 | @font-face { 5 | font-family: 'JetBrains Mono'; 6 | font-style: normal; 7 | font-weight: 400; 8 | font-display: swap; 9 | src: url(https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxTOlOV.woff2) format('woff2'); 10 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 11 | } 12 | 13 | @font-face { 14 | font-family: 'Fira Code'; 15 | font-style: normal; 16 | font-weight: 400; 17 | font-display: swap; 18 | src: url(https://fonts.gstatic.com/s/firacode/v22/uU9eCBsR6Z2vfE9aq3bL0fxyUs4tcw4W_D1sJV37Nv7g.woff2) format('woff2'); 19 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Ubuntu Mono'; 24 | font-style: normal; 25 | font-weight: 400; 26 | font-display: swap; 27 | src: url(https://fonts.gstatic.com/s/ubuntumono/v15/KFOjCneDtsqEr0keqCMhbCc6CsQ.woff2) format('woff2'); 28 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 29 | } 30 | 31 | @font-face { 32 | font-family: 'Source Code Pro'; 33 | font-style: normal; 34 | font-weight: 400; 35 | font-display: swap; 36 | src: url(https://fonts.gstatic.com/s/sourcecodepro/v23/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DMyQtMlrTA.woff2) format('woff2'); 37 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 38 | } 39 | 40 | @font-face { 41 | font-family: 'Space Mono'; 42 | font-style: normal; 43 | font-weight: 400; 44 | font-display: swap; 45 | src: url(https://fonts.gstatic.com/s/spacemono/v13/i7dPIFZifjKcF5UAWdDRYEF8RQ.woff2) format('woff2'); 46 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 47 | } 48 | 49 | @font-face { 50 | font-family: 'IBM Plex Mono'; 51 | font-style: normal; 52 | font-weight: 400; 53 | font-display: swap; 54 | src: url(https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1i8q131nj-o.woff2) format('woff2'); 55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 56 | } 57 | 58 | @font-face { 59 | font-family: 'Inconsolata'; 60 | font-style: normal; 61 | font-weight: 400; 62 | font-display: swap; 63 | src: url(https://fonts.gstatic.com/s/inconsolata/v32/QlddNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwEYxs.woff2) format('woff2'); 64 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 65 | } 66 | 67 | @font-face { 68 | font-family: 'Anonymous Pro'; 69 | font-style: normal; 70 | font-weight: 400; 71 | font-display: swap; 72 | src: url(https://fonts.gstatic.com/s/anonymouspro/v21/rP2Bp2a15UIB-sM7tDNW65jPCaA.woff2) format('woff2'); 73 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 74 | } 75 | 76 | .tab-group::after { 77 | content: ''; 78 | width: 1px; 79 | height: 24px; 80 | background-color: #4a5568; 81 | margin: 0 8px; 82 | } 83 | 84 | .terminal-container { 85 | min-height: 0; 86 | overflow: hidden; 87 | } 88 | 89 | .terminal-container > div { 90 | min-height: 0; 91 | border-radius: 0.5rem; 92 | overflow: hidden; 93 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 94 | transition: background-color 0.2s ease-in-out; 95 | border: 3px solid rgba(255, 255, 255, 0.15); 96 | display: flex !important; 97 | flex-direction: column !important; 98 | width: 100% !important; 99 | height: 100% !important; 100 | } 101 | 102 | .terminal-container > div[style*="background-color: #ffffff"], 103 | .terminal-container > div[style*="background-color: rgb(255, 255, 255)"] { 104 | border: 3px solid rgba(0, 0, 0, 0.15); 105 | } 106 | 107 | .xterm-viewport { 108 | width: 100% !important; 109 | overflow-x: hidden !important; 110 | } 111 | 112 | .xterm-rows span { 113 | text-rendering: optimizeSpeed; 114 | font-variant-ligatures: none !important; 115 | letter-spacing: normal !important; 116 | } 117 | 118 | .xterm { 119 | width: 100% !important; 120 | height: 100% !important; 121 | } 122 | 123 | .xterm-screen { 124 | width: 100% !important; 125 | } 126 | 127 | .xterm-rows { 128 | width: 100% !important; 129 | } 130 | 131 | .terminal { 132 | box-sizing: content-box !important; 133 | padding: 0 !important; 134 | } 135 | 136 | .xterm[style*="fontFamily: monospace"] .xterm-rows span, 137 | .xterm[style*="fontFamily: ui-monospace"] .xterm-rows span { 138 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important; 139 | font-feature-settings: normal; 140 | letter-spacing: 0 !important; 141 | font-variant-ligatures: none; 142 | } 143 | 144 | .xterm[style*="fontFamily: \"Inconsolata\""] .xterm-rows span, 145 | .xterm[style*="fontFamily: Inconsolata"] .xterm-rows span { 146 | font-family: "Inconsolata", Consolas, monospace !important; 147 | font-feature-settings: "calt", "ss01"; 148 | letter-spacing: 0.03em !important; 149 | font-weight: 500 !important; 150 | font-variant-ligatures: none; 151 | } 152 | 153 | .xterm[style*="fontFamily: \"Fira Code\""] .xterm-rows span, 154 | .xterm[style*="fontFamily: Fira Code"] .xterm-rows span { 155 | font-family: "Fira Code", monospace !important; 156 | font-feature-settings: "calt" 1, "liga" 1; 157 | letter-spacing: 0 !important; 158 | font-variant-ligatures: contextual !important; 159 | text-rendering: optimizeLegibility !important; 160 | } 161 | 162 | .xterm[style*="fontFamily: \"JetBrains Mono\""] .xterm-rows span, 163 | .xterm[style*="fontFamily: JetBrains Mono"] .xterm-rows span { 164 | font-family: "JetBrains Mono", monospace !important; 165 | font-feature-settings: "calt" 1, "ss01" 1; 166 | letter-spacing: 0 !important; 167 | font-variant-ligatures: contextual !important; 168 | } 169 | 170 | .xterm[style*="fontFamily: \"Source Code Pro\""] .xterm-rows span, 171 | .xterm[style*="fontFamily: Source Code Pro"] .xterm-rows span { 172 | font-family: "Source Code Pro", monospace !important; 173 | font-feature-settings: "calt" 0; 174 | letter-spacing: 0.01em !important; 175 | font-variant-ligatures: none; 176 | font-weight: 500 !important; 177 | } 178 | 179 | .xterm[style*="fontFamily: \"Ubuntu Mono\""] .xterm-rows span, 180 | .xterm[style*="fontFamily: Ubuntu Mono"] .xterm-rows span { 181 | font-family: "Ubuntu Mono", monospace !important; 182 | font-feature-settings: normal; 183 | letter-spacing: 0.05em !important; 184 | font-variant-ligatures: none; 185 | font-weight: 500 !important; 186 | } 187 | 188 | .xterm[style*="fontFamily: \"Space Mono\""] .xterm-rows span, 189 | .xterm[style*="fontFamily: Space Mono"] .xterm-rows span { 190 | font-family: "Space Mono", "Cascadia Code", "Cascadia Mono", monospace !important; 191 | font-feature-settings: "calt" 0; 192 | letter-spacing: 0 !important; 193 | font-variant-ligatures: none; 194 | font-weight: 400 !important; 195 | } 196 | 197 | .xterm[style*="fontFamily: \"IBM Plex Mono\""] .xterm-rows span, 198 | .xterm[style*="fontFamily: IBM Plex Mono"] .xterm-rows span { 199 | font-family: "IBM Plex Mono", Menlo, Monaco, monospace !important; 200 | font-feature-settings: "calt" 1, "ss02" 1; 201 | letter-spacing: 0 !important; 202 | font-variant-ligatures: none; 203 | font-weight: 500 !important; 204 | } 205 | 206 | .xterm[style*="fontFamily: \"Roboto Mono\""] .xterm-rows span, 207 | .xterm[style*="fontFamily: Roboto Mono"] .xterm-rows span { 208 | font-family: "Roboto Mono", monospace !important; 209 | font-feature-settings: "calt" 0; 210 | letter-spacing: 0.01em !important; 211 | font-variant-ligatures: none; 212 | font-weight: 400 !important; 213 | } 214 | 215 | .xterm[style*="fontFamily: \"Anonymous Pro\""] .xterm-rows span, 216 | .xterm[style*="fontFamily: Anonymous Pro"] .xterm-rows span { 217 | font-family: "Anonymous Pro", monospace !important; 218 | font-feature-settings: normal; 219 | letter-spacing: 0.02em !important; 220 | font-variant-ligatures: none; 221 | font-weight: 400 !important; 222 | } 223 | 224 | .xterm[style*="fontFamily: \"Hack\""] .xterm-rows span, 225 | .xterm[style*="fontFamily: Hack"] .xterm-rows span, 226 | .xterm[style*="fontFamily: \"Hack Nerd Font\""] .xterm-rows span, 227 | .xterm[style*="fontFamily: \"Hack Nerd Font Mono\""] .xterm-rows spa 228 | , 229 | .xterm[style*="fontFamily: \"FiraCode Nerd Font\""] .xterm-rows span 230 | 231 | .xterm[style*="fontFamily: \"FiraCode Nerd Font Mono\""] .xterm-rows 232 | span, 233 | .xterm[style*="fontFamily: \"JetBrainsMono Nerd Font\""] .xterm-rows 234 | span, 235 | .xterm[style*="fontFamily: \"JetBrainsMono Nerd Font Mono\""] .xterm 236 | rows span, 237 | .xterm[style*="fontFamily: Symbols Nerd Font"] .xterm-rows span, 238 | .xterm[style*="fontFamily: \"Symbols Nerd Font Mono\""] .xterm-rows 239 | pan { 240 | font-variant-ligatures: no-contextual !important; 241 | font-feature-settings: "liga" 0, "calt" 0, "dlig" 0 !important; 242 | text-rendering: optimizeLegibility !important; 243 | } 244 | 245 | .tablist::-webkit-scrollbar { 246 | width: 1px !important; 247 | height: 1px !important; 248 | background: transparent !important; 249 | } 250 | 251 | .tablist::-webkit-scrollbar-thumb { 252 | background: rgba(255, 255, 255, 0.2) !important; 253 | } 254 | 255 | ::-webkit-scrollbar { 256 | width: 8px; 257 | height: 8px; 258 | background: transparent; 259 | } 260 | 261 | ::-webkit-scrollbar-thumb { 262 | background: rgba(255, 255, 255, 0.2); 263 | } 264 | 265 | .terminal-container > div > div { 266 | flex: 1 !important; 267 | width: 100% !important; 268 | max-width: 100% !important; 269 | } 270 | 271 | .terminal-container > div { 272 | display: flex !important; 273 | flex-direction: column !important; 274 | width: 100% !important; 275 | height: 100% !important; 276 | } 277 | 278 | .font-mono { 279 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important; 280 | } 281 | 282 | .font-inconsolata { 283 | font-family: "Inconsolata", Consolas, monospace !important; 284 | letter-spacing: 0.03em !important; 285 | font-weight: 500 !important; 286 | } 287 | 288 | .font-fira { 289 | font-family: "Fira Code", monospace !important; 290 | font-variant-ligatures: contextual !important; 291 | text-rendering: optimizeLegibility !important; 292 | } 293 | 294 | .font-jetbrains { 295 | font-family: "JetBrains Mono", monospace !important; 296 | font-feature-settings: "calt" 1, "ss01" 1; 297 | } 298 | 299 | .font-source { 300 | font-family: "Source Code Pro", monospace !important; 301 | font-weight: 500 !important; 302 | } 303 | 304 | .font-ubuntu { 305 | font-family: "Ubuntu Mono", monospace !important; 306 | letter-spacing: 0.05em !important; 307 | font-weight: 500 !important; 308 | } 309 | 310 | .font-space { 311 | font-family: "Space Mono", "Cascadia Code", "Cascadia Mono", monospace !important; 312 | font-weight: 400 !important; 313 | letter-spacing: 0 !important; 314 | } 315 | 316 | .font-ibm { 317 | font-family: "IBM Plex Mono", Menlo, Monaco, monospace !important; 318 | font-weight: 500 !important; 319 | } 320 | 321 | .font-roboto { 322 | font-family: "Roboto Mono", monospace !important; 323 | font-weight: 400 !important; 324 | } 325 | 326 | .font-anonymous { 327 | font-family: "Anonymous Pro", monospace !important; 328 | letter-spacing: 0.02em !important; 329 | } 330 | 331 | .font-nerd { 332 | font-family: "Hack Nerd Font Mono", "Hack Nerd Font", "FiraCode Nerd Font Mono", "JetBrainsMono Nerd Font Mono", "Symbols Nerd Font Mono", monospace !important; 333 | letter-spacing: 0 !important; 334 | font-variant-ligatures: no-contextual !important; 335 | font-feature-settings: "liga" 0, "calt" 0, "dlig" 0 !important; 336 | } -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.jsx' 5 | import { preloadAllFonts } from './utils/fontLoader.js' 6 | 7 | preloadAllFonts(); 8 | 9 | ReactDOM.createRoot(document.getElementById('root')).render( 10 | 11 | ) -------------------------------------------------------------------------------- /src/modals/AdminModal.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { useState, useEffect } from 'react'; 3 | import { CssVarsProvider } from '@mui/joy/styles'; 4 | import { 5 | Modal, 6 | Button, 7 | FormControl, 8 | FormLabel, 9 | Input, 10 | DialogTitle, 11 | DialogContent, 12 | ModalDialog, 13 | List, 14 | ListItem, 15 | Typography, 16 | Divider, 17 | Switch 18 | } from '@mui/joy'; 19 | import theme from '/src/theme'; 20 | 21 | const AdminModal = ({ 22 | isHidden, 23 | setIsHidden, 24 | handleAddAdmin, 25 | handleToggleAccountCreation, 26 | checkAccountCreationStatus, 27 | getAllAdmins, 28 | }) => { 29 | const [username, setUsername] = useState(''); 30 | const [isLoading, setIsLoading] = useState(false); 31 | const [accountCreationEnabled, setAccountCreationEnabled] = useState(true); 32 | const [admins, setAdmins] = useState([]); 33 | 34 | useEffect(() => { 35 | if (!isHidden) { 36 | loadData(); 37 | } 38 | }, [isHidden]); 39 | 40 | const loadData = async () => { 41 | setIsLoading(true); 42 | 43 | try { 44 | const status = await checkAccountCreationStatus(); 45 | setAccountCreationEnabled(status.allowed); 46 | 47 | const adminList = await getAllAdmins(); 48 | setAdmins(adminList); 49 | } catch (error) { 50 | } finally { 51 | setIsLoading(false); 52 | } 53 | }; 54 | 55 | const handleSubmit = async (event) => { 56 | event.preventDefault(); 57 | event.stopPropagation(); 58 | if (isLoading || !username.trim()) return; 59 | 60 | setIsLoading(true); 61 | 62 | try { 63 | const result = await handleAddAdmin(username.trim()); 64 | if (result) { 65 | setUsername(''); 66 | await loadData(); 67 | } 68 | } catch (error) { 69 | } finally { 70 | setIsLoading(false); 71 | } 72 | }; 73 | 74 | const handleToggle = async () => { 75 | setIsLoading(true); 76 | 77 | try { 78 | const result = await handleToggleAccountCreation(!accountCreationEnabled); 79 | if (result !== null) { 80 | setAccountCreationEnabled(result); 81 | } 82 | } catch (error) { 83 | } finally { 84 | setIsLoading(false); 85 | } 86 | }; 87 | 88 | const handleModalClick = (event) => { 89 | event.stopPropagation(); 90 | }; 91 | 92 | return ( 93 | 94 | !isLoading && setIsHidden(true)} 97 | sx={{ 98 | position: 'fixed', 99 | inset: 0, 100 | display: 'flex', 101 | alignItems: 'center', 102 | justifyContent: 'center', 103 | backdropFilter: 'blur(5px)', 104 | backgroundColor: 'rgba(0, 0, 0, 0.2)', 105 | }} 106 | > 107 | 123 | Admin Panel 124 | 125 |
e.stopPropagation()}> 126 | 127 | Add a new admin 128 | setUsername(e.target.value)} 131 | placeholder="Enter username" 132 | onClick={(e) => e.stopPropagation()} 133 | sx={{ 134 | backgroundColor: theme.palette.general.primary, 135 | color: theme.palette.text.primary, 136 | mb: 2 137 | }} 138 | /> 139 | 159 | 160 | 161 | 162 | 163 | 164 | Account Creation 165 |
174 | 175 | {accountCreationEnabled ? "Enabled" : "Disabled"} 176 | 177 | 182 |
183 |
184 | 185 | 186 | 187 | Current Admins: 188 | 194 | {admins.length > 0 ? ( 195 | admins.map((admin, index) => ( 196 | 197 | {admin.username || admin} 198 | 199 | )) 200 | ) : ( 201 | No admins found 202 | )} 203 | 204 | 205 | 220 | 221 |
222 |
223 |
224 |
225 | ); 226 | }; 227 | 228 | AdminModal.propTypes = { 229 | isHidden: PropTypes.bool.isRequired, 230 | setIsHidden: PropTypes.func.isRequired, 231 | handleAddAdmin: PropTypes.func.isRequired, 232 | handleToggleAccountCreation: PropTypes.func.isRequired, 233 | checkAccountCreationStatus: PropTypes.func.isRequired, 234 | getAllAdmins: PropTypes.func.isRequired, 235 | adminErrorMessage: PropTypes.string, 236 | setAdminErrorMessage: PropTypes.func 237 | }; 238 | 239 | export default AdminModal; -------------------------------------------------------------------------------- /src/modals/AuthModal.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { CssVarsProvider } from '@mui/joy/styles'; 3 | import { 4 | Modal, 5 | Button, 6 | FormControl, 7 | FormLabel, 8 | Input, 9 | Stack, 10 | DialogContent, 11 | ModalDialog, 12 | IconButton, 13 | Tabs, 14 | TabList, 15 | Tab, 16 | TabPanel, 17 | Alert 18 | } from '@mui/joy'; 19 | import theme from '/src/theme'; 20 | import { useEffect, useState } from 'react'; 21 | import Visibility from '@mui/icons-material/Visibility'; 22 | import VisibilityOff from '@mui/icons-material/VisibilityOff'; 23 | import eventBus from '/src/other/eventBus'; 24 | 25 | const AuthModal = ({ 26 | isHidden, 27 | form, 28 | setForm, 29 | handleLoginUser, 30 | handleCreateUser, 31 | handleGuestLogin, 32 | setIsAuthModalHidden, 33 | checkAccountCreationStatus 34 | }) => { 35 | const [activeTab, setActiveTab] = useState(0); 36 | const [showPassword, setShowPassword] = useState(false); 37 | const [showConfirmPassword, setShowConfirmPassword] = useState(false); 38 | const [isLoading, setIsLoading] = useState(false); 39 | const [isAccountCreationAllowed, setIsAccountCreationAllowed] = useState(true); 40 | const [isFirstUser, setIsFirstUser] = useState(false); 41 | 42 | useEffect(() => { 43 | const loginErrorHandler = () => setIsLoading(false); 44 | eventBus.on('failedLoginUser', loginErrorHandler); 45 | return () => eventBus.off('failedLoginUser', loginErrorHandler); 46 | }, []); 47 | 48 | useEffect(() => { 49 | if (!isHidden && checkAccountCreationStatus) { 50 | const checkStatus = async () => { 51 | const status = await checkAccountCreationStatus(); 52 | setIsAccountCreationAllowed(status.allowed); 53 | setIsFirstUser(status.isFirstUser); 54 | if (!status.allowed && !status.isFirstUser && activeTab === 1) { 55 | setActiveTab(0); 56 | } 57 | }; 58 | checkStatus(); 59 | } 60 | }, [isHidden, activeTab, checkAccountCreationStatus]); 61 | 62 | const resetForm = () => { 63 | setForm({ username: '', password: '' }); 64 | setShowPassword(false); 65 | setShowConfirmPassword(false); 66 | setIsLoading(false); 67 | }; 68 | 69 | const handleLogin = async () => { 70 | setIsLoading(true); 71 | try { 72 | await handleLoginUser({ 73 | ...form, 74 | onSuccess: () => { 75 | setIsLoading(false); 76 | setIsAuthModalHidden(true); 77 | }, 78 | onFailure: () => setIsLoading(false), 79 | }); 80 | } catch (error) { 81 | setIsLoading(false); 82 | } 83 | }; 84 | 85 | const handleCreate = async () => { 86 | setIsLoading(true); 87 | try { 88 | await handleCreateUser({ 89 | ...form, 90 | onSuccess: () => { 91 | setIsLoading(false); 92 | setActiveTab(0); 93 | setIsAuthModalHidden(true); 94 | }, 95 | onFailure: () => setIsLoading(false), 96 | }); 97 | } catch (error) { 98 | setIsLoading(false); 99 | } 100 | }; 101 | 102 | const handleGuest = async () => { 103 | setIsLoading(true); 104 | try { 105 | await handleGuestLogin({ 106 | onSuccess: () => { 107 | setIsLoading(false); 108 | setIsAuthModalHidden(true); 109 | }, 110 | onFailure: () => setIsLoading(false) 111 | }); 112 | } catch (error) { 113 | setIsLoading(false); 114 | } 115 | }; 116 | 117 | useEffect(() => { 118 | if (isHidden) resetForm(); 119 | }, [isHidden]); 120 | 121 | const isLoginValid = !!form.username && !!form.password; 122 | const isCreateValid = isLoginValid && form.password === form.confirmPassword; 123 | 124 | return ( 125 | 126 | setIsAuthModalHidden(true)}> 127 | 141 | { 144 | if (val === 1 && !isAccountCreationAllowed && !isFirstUser) { 145 | return; 146 | } 147 | setActiveTab(val); 148 | }} 149 | sx={{ 150 | width: '100%', 151 | backgroundColor: theme.palette.general.tertiary, 152 | }} 153 | > 154 | 181 | Login 182 | Create 183 | 184 | 185 | 186 | {!isAccountCreationAllowed && !isFirstUser && activeTab === 0 && ( 187 | 191 | Account creation has been disabled by an administrator. 192 | 193 | )} 194 | 195 | 196 | { e.preventDefault(); handleLogin(); }}> 197 | 198 | Username 199 | setForm({ ...form, username: e.target.value })} 203 | sx={inputStyle} 204 | /> 205 | 206 | 207 | Password 208 |
209 | setForm({ ...form, password: e.target.value })} 214 | sx={{ ...inputStyle, flex: 1 }} 215 | /> 216 | setShowPassword(!showPassword)} 219 | sx={iconButtonStyle} 220 | > 221 | {showPassword ? : } 222 | 223 |
224 |
225 | 232 | 239 |
240 |
241 | 242 | 243 | {isFirstUser && ( 244 | 248 | You will be the first user and will have administrator privileges. 249 | 250 | )} 251 | 252 | { e.preventDefault(); handleCreate(); }}> 253 | 254 | Username 255 | setForm({ ...form, username: e.target.value })} 259 | sx={inputStyle} 260 | /> 261 | 262 | 263 | Password 264 |
265 | setForm({ ...form, password: e.target.value })} 270 | sx={{ ...inputStyle, flex: 1 }} 271 | /> 272 | setShowPassword(!showPassword)} 275 | sx={iconButtonStyle} 276 | > 277 | {showPassword ? : } 278 | 279 |
280 |
281 | 282 | Confirm Password 283 |
284 | setForm({ ...form, confirmPassword: e.target.value })} 289 | sx={{ ...inputStyle, flex: 1 }} 290 | /> 291 | setShowConfirmPassword(!showConfirmPassword)} 294 | sx={iconButtonStyle} 295 | > 296 | {showConfirmPassword ? : } 297 | 298 |
299 |
300 | 307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 | ); 315 | }; 316 | 317 | const inputStyle = { 318 | backgroundColor: theme.palette.general.primary, 319 | color: theme.palette.text.primary, 320 | '&:disabled': { 321 | opacity: 0.5, 322 | backgroundColor: theme.palette.general.primary, 323 | }, 324 | }; 325 | 326 | const iconButtonStyle = { 327 | color: theme.palette.text.primary, 328 | marginLeft: 1, 329 | '&:disabled': { opacity: 0.5 }, 330 | }; 331 | 332 | const buttonStyle = { 333 | backgroundColor: theme.palette.general.primary, 334 | '&:hover': { backgroundColor: theme.palette.general.disabled }, 335 | '&:disabled': { 336 | opacity: 0.5, 337 | backgroundColor: theme.palette.general.primary, 338 | }, 339 | }; 340 | 341 | AuthModal.propTypes = { 342 | isHidden: PropTypes.bool.isRequired, 343 | form: PropTypes.object.isRequired, 344 | setForm: PropTypes.func.isRequired, 345 | handleLoginUser: PropTypes.func.isRequired, 346 | handleCreateUser: PropTypes.func.isRequired, 347 | handleGuestLogin: PropTypes.func.isRequired, 348 | setIsAuthModalHidden: PropTypes.func.isRequired, 349 | checkAccountCreationStatus: PropTypes.func 350 | }; 351 | 352 | export default AuthModal; -------------------------------------------------------------------------------- /src/modals/ConfirmDeleteModal.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { Modal, Button } from "@mui/joy"; 3 | import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; 4 | import theme from "../theme"; 5 | 6 | export default function ConfirmDeleteModal({ 7 | isHidden, 8 | title, 9 | message, 10 | itemName, 11 | onConfirm, 12 | onCancel, 13 | }) { 14 | return ( 15 | 24 |
33 |
34 |

{title}

35 | 36 |

37 | {message} 38 | {itemName && {itemName}} 39 |

40 | 41 |
42 | 57 | 58 | 75 |
76 |
77 |
78 |
79 | ); 80 | } 81 | 82 | ConfirmDeleteModal.propTypes = { 83 | isHidden: PropTypes.bool.isRequired, 84 | title: PropTypes.string.isRequired, 85 | message: PropTypes.string.isRequired, 86 | itemName: PropTypes.string, 87 | onConfirm: PropTypes.func.isRequired, 88 | onCancel: PropTypes.func.isRequired, 89 | }; -------------------------------------------------------------------------------- /src/modals/ErrorModal.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { CssVarsProvider } from '@mui/joy/styles'; 3 | import { Modal, Button, DialogTitle, DialogContent, ModalDialog } from '@mui/joy'; 4 | import theme from '/src/theme'; 5 | 6 | const ErrorModal = ({ isHidden, errorMessage, setIsErrorHidden }) => { 7 | return ( 8 | 9 | setIsErrorHidden(true)}> 10 | 29 | Error 30 | 31 | {errorMessage} 32 | 33 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | ErrorModal.propTypes = { 51 | isHidden: PropTypes.bool.isRequired, 52 | errorMessage: PropTypes.string.isRequired, 53 | setIsErrorHidden: PropTypes.func.isRequired, 54 | }; 55 | 56 | export default ErrorModal; -------------------------------------------------------------------------------- /src/modals/InfoModal.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { CssVarsProvider } from '@mui/joy/styles'; 3 | import { Modal, Button, DialogTitle, DialogContent, ModalDialog } from '@mui/joy'; 4 | import theme from '/src/theme'; 5 | 6 | const InfoModal = ({ isHidden, infoMessage, title, setIsInfoHidden }) => { 7 | return ( 8 | 9 | setIsInfoHidden(true)} 12 | sx={{ 13 | display: 'flex', 14 | justifyContent: 'center', 15 | alignItems: 'center', 16 | }} 17 | > 18 | 41 | {title || "Information"} 42 | 47 | {infoMessage} 48 | 49 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | InfoModal.propTypes = { 68 | isHidden: PropTypes.bool.isRequired, 69 | infoMessage: PropTypes.string.isRequired, 70 | title: PropTypes.string, 71 | setIsInfoHidden: PropTypes.func.isRequired, 72 | }; 73 | 74 | export default InfoModal; -------------------------------------------------------------------------------- /src/modals/NoAuthenticationModal.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { CssVarsProvider } from '@mui/joy/styles'; 3 | import { 4 | Modal, 5 | Button, 6 | FormControl, 7 | FormLabel, 8 | Input, 9 | Stack, 10 | DialogTitle, 11 | DialogContent, 12 | ModalDialog, 13 | IconButton, 14 | Select, 15 | Option, 16 | } from '@mui/joy'; 17 | import theme from '/src/theme'; 18 | import {useEffect, useState} from 'react'; 19 | import Visibility from '@mui/icons-material/Visibility'; 20 | import VisibilityOff from '@mui/icons-material/VisibilityOff'; 21 | 22 | const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, handleAuthSubmit }) => { 23 | const [showPassword, setShowPassword] = useState(false); 24 | 25 | useEffect(() => { 26 | if (!form.authMethod) { 27 | setForm(prev => ({ 28 | ...prev, 29 | authMethod: 'Select Auth', 30 | password: '', 31 | sshKey: '', 32 | keyType: '', 33 | })); 34 | } 35 | }, []); 36 | 37 | const isFormValid = () => { 38 | if (!form.authMethod || form.authMethod === 'Select Auth') return false; 39 | if (form.authMethod === 'sshKey' && !form.sshKey) return false; 40 | if (form.authMethod === 'password' && !form.password) return false; 41 | return true; 42 | }; 43 | 44 | const handleSubmit = (e) => { 45 | e.preventDefault(); 46 | e.stopPropagation(); 47 | 48 | try { 49 | if(isFormValid()) { 50 | const formData = { 51 | authMethod: form.authMethod, 52 | password: form.authMethod === 'password' ? form.password : '', 53 | sshKey: form.authMethod === 'sshKey' ? form.sshKey : '', 54 | keyType: form.authMethod === 'sshKey' ? form.keyType : '', 55 | }; 56 | 57 | handleAuthSubmit(formData); 58 | 59 | setForm(prev => ({ 60 | ...prev, 61 | authMethod: 'Select Auth', 62 | password: '', 63 | sshKey: '', 64 | keyType: '', 65 | })); 66 | } 67 | } catch (error) { 68 | } 69 | }; 70 | 71 | const handleFileChange = (e) => { 72 | const file = e.target.files[0]; 73 | const supportedKeyTypes = { 74 | 'id_rsa': 'RSA', 75 | 'id_ed25519': 'ED25519', 76 | 'id_ecdsa': 'ECDSA', 77 | 'id_dsa': 'DSA', 78 | '.pem': 'PEM', 79 | '.key': 'KEY', 80 | '.ppk': 'PPK' 81 | }; 82 | 83 | const isValidKeyFile = Object.keys(supportedKeyTypes).some(ext => 84 | file.name.toLowerCase().includes(ext) || file.name.endsWith('.pub') 85 | ); 86 | 87 | if (isValidKeyFile) { 88 | const reader = new FileReader(); 89 | reader.onload = (event) => { 90 | const keyContent = event.target.result; 91 | let keyType = 'UNKNOWN'; 92 | 93 | if (keyContent.includes('BEGIN RSA PRIVATE KEY') || keyContent.includes('BEGIN RSA PUBLIC KEY')) { 94 | keyType = 'RSA'; 95 | } else if (keyContent.includes('BEGIN OPENSSH PRIVATE KEY') && keyContent.includes('ssh-ed25519')) { 96 | keyType = 'ED25519'; 97 | } else if (keyContent.includes('BEGIN EC PRIVATE KEY') || keyContent.includes('BEGIN EC PUBLIC KEY')) { 98 | keyType = 'ECDSA'; 99 | } else if (keyContent.includes('BEGIN DSA PRIVATE KEY')) { 100 | keyType = 'DSA'; 101 | } 102 | 103 | setForm({ 104 | ...form, 105 | sshKey: keyContent, 106 | keyType: keyType, 107 | authMethod: 'sshKey' 108 | }); 109 | }; 110 | reader.readAsText(file); 111 | } else { 112 | alert('Please upload a valid SSH key file (RSA, ED25519, ECDSA, DSA, PEM, or PPK format).'); 113 | } 114 | }; 115 | 116 | return ( 117 | 118 | { 121 | if (reason !== 'backdropClick') { 122 | setIsNoAuthHidden(true); 123 | } 124 | }} 125 | sx={{ 126 | display: 'flex', 127 | justifyContent: 'center', 128 | alignItems: 'center', 129 | }} 130 | > 131 | 147 | Authentication Required 148 | 149 |
150 | 151 | 152 | Authentication Method 153 | 171 | 172 | 173 | {form.authMethod === 'password' && ( 174 | 175 | Password 176 |
177 | setForm({...form, password: e.target.value})} 181 | sx={{ 182 | backgroundColor: theme.palette.general.primary, 183 | color: theme.palette.text.primary, 184 | flex: 1 185 | }} 186 | /> 187 | setShowPassword(!showPassword)} 189 | sx={{ 190 | color: theme.palette.text.primary, 191 | marginLeft: 1, 192 | '&:disabled': { 193 | opacity: 0.5, 194 | }, 195 | }} 196 | > 197 | {showPassword ? : } 198 | 199 |
200 |
201 | )} 202 | 203 | {form.authMethod === 'sshKey' && ( 204 | 205 | 206 | SSH Key 207 | 229 | 230 | 231 | )} 232 | 233 | 252 |
253 |
254 |
255 |
256 |
257 |
258 | ); 259 | }; 260 | 261 | NoAuthenticationModal.propTypes = { 262 | isHidden: PropTypes.bool.isRequired, 263 | form: PropTypes.object.isRequired, 264 | setForm: PropTypes.func.isRequired, 265 | setIsNoAuthHidden: PropTypes.func.isRequired, 266 | handleAuthSubmit: PropTypes.func.isRequired, 267 | }; 268 | 269 | export default NoAuthenticationModal; -------------------------------------------------------------------------------- /src/modals/ProfileModal.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { useState } from 'react'; 3 | import { Modal, Button } from "@mui/joy"; 4 | import LogoutIcon from "@mui/icons-material/Logout"; 5 | import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; 6 | import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; 7 | import ConfirmDeleteModal from "./ConfirmDeleteModal"; 8 | import AdminModal from "./AdminModal"; 9 | import theme from "../theme"; 10 | 11 | export default function ProfileModal({ 12 | isHidden, 13 | getUser, 14 | handleDeleteUser, 15 | handleLogoutUser, 16 | setIsProfileHidden, 17 | handleAddAdmin, 18 | handleToggleAccountCreation, 19 | checkAccountCreationStatus, 20 | getAllAdmins, 21 | adminErrorMessage, 22 | setAdminErrorMessage 23 | }) { 24 | const [isConfirmDeleteHidden, setIsConfirmDeleteHidden] = useState(true); 25 | const [isAdminModalHidden, setIsAdminModalHidden] = useState(true); 26 | const user = getUser(); 27 | const username = user?.username; 28 | const isAdmin = user?.isAdmin || false; 29 | 30 | return ( 31 | <> 32 | setIsProfileHidden(true)} 35 | sx={{ 36 | display: "flex", 37 | justifyContent: "center", 38 | alignItems: "center", 39 | }} 40 | > 41 |
50 |
51 | {isAdmin && ( 52 | 68 | )} 69 | 70 | 86 | 87 | 104 | 105 |
106 | v0.3 107 |
108 |
109 |
110 |
111 | 112 | { 118 | handleDeleteUser({ 119 | onSuccess: () => { 120 | setIsConfirmDeleteHidden(true); 121 | setIsProfileHidden(true); 122 | }, 123 | }); 124 | }} 125 | onCancel={() => setIsConfirmDeleteHidden(true)} 126 | /> 127 | 128 | {isAdmin && ( 129 | 139 | )} 140 | 141 | ); 142 | } 143 | 144 | ProfileModal.propTypes = { 145 | isHidden: PropTypes.bool.isRequired, 146 | getUser: PropTypes.func.isRequired, 147 | handleDeleteUser: PropTypes.func.isRequired, 148 | handleLogoutUser: PropTypes.func.isRequired, 149 | setIsProfileHidden: PropTypes.func.isRequired, 150 | handleAddAdmin: PropTypes.func, 151 | handleToggleAccountCreation: PropTypes.func, 152 | checkAccountCreationStatus: PropTypes.func, 153 | getAllAdmins: PropTypes.func, 154 | adminErrorMessage: PropTypes.string, 155 | setAdminErrorMessage: PropTypes.func 156 | }; -------------------------------------------------------------------------------- /src/modals/ShareHostModal.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { useState } from 'react'; 3 | import { CssVarsProvider } from '@mui/joy/styles'; 4 | import { 5 | Modal, 6 | Button, 7 | FormControl, 8 | FormLabel, 9 | Input, 10 | DialogTitle, 11 | DialogContent, 12 | ModalDialog, 13 | } from '@mui/joy'; 14 | import theme from '/src/theme'; 15 | 16 | const ShareHostModal = ({ isHidden, setIsHidden, handleShare, hostConfig }) => { 17 | const [username, setUsername] = useState(''); 18 | const [isLoading, setIsLoading] = useState(false); 19 | 20 | const handleSubmit = async (event) => { 21 | event.preventDefault(); 22 | event.stopPropagation(); 23 | if (isLoading || !username.trim()) return; 24 | 25 | setIsLoading(true); 26 | try { 27 | await handleShare(hostConfig._id, username.trim()); 28 | setUsername(''); 29 | setIsHidden(true); 30 | } finally { 31 | setIsLoading(false); 32 | } 33 | }; 34 | 35 | const handleModalClick = (event) => { 36 | event.stopPropagation(); 37 | }; 38 | 39 | return ( 40 | 41 | !isLoading && setIsHidden(true)} 44 | sx={{ 45 | position: 'fixed', 46 | inset: 0, 47 | display: 'flex', 48 | alignItems: 'center', 49 | justifyContent: 'center', 50 | backdropFilter: 'blur(5px)', 51 | backgroundColor: 'rgba(0, 0, 0, 0.2)', 52 | }} 53 | > 54 | 70 | Share Host 71 | 72 |
e.stopPropagation()}> 73 | 74 | Username to share with 75 | setUsername(e.target.value)} 78 | placeholder="Enter username" 79 | onClick={(e) => e.stopPropagation()} 80 | sx={{ 81 | backgroundColor: theme.palette.general.primary, 82 | color: theme.palette.text.primary, 83 | mb: 2 84 | }} 85 | /> 86 | 87 | 88 | 108 |
109 |
110 |
111 |
112 |
113 | ); 114 | }; 115 | 116 | ShareHostModal.propTypes = { 117 | isHidden: PropTypes.bool.isRequired, 118 | setIsHidden: PropTypes.func.isRequired, 119 | handleShare: PropTypes.func.isRequired, 120 | hostConfig: PropTypes.object 121 | }; 122 | 123 | export default ShareHostModal; -------------------------------------------------------------------------------- /src/other/Utils.jsx: -------------------------------------------------------------------------------- 1 | export const Debounce = (func, wait) => { 2 | let timeout; 3 | return (...args) => { 4 | clearTimeout(timeout); 5 | timeout = setTimeout(() => func.apply(this, args), wait); 6 | }; 7 | }; -------------------------------------------------------------------------------- /src/other/eventBus.jsx: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | 3 | const eventBus = mitt(); 4 | 5 | export default eventBus; -------------------------------------------------------------------------------- /src/theme.js: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@mui/joy/styles'; 2 | 3 | const theme = extendTheme({ 4 | colorSchemes: { 5 | light: { 6 | palette: { 7 | neutral: { 8 | 50: '#f7f7f7', 9 | 100: '#e1e1e1', 10 | 200: '#c4c4c4', 11 | 300: '#a7a7a7', 12 | 400: '#8a8a8a', 13 | 500: '#6e6e6e', 14 | 600: '#555555', 15 | 700: '#3d3d3d', 16 | 800: '#262626', 17 | 900: '#0f0f0f', 18 | }, 19 | background: { 20 | primary: '#3d3d3d', 21 | terminal: '#262626', 22 | paper: '#555555', 23 | }, 24 | text: { 25 | primary: '#f7f7f7', 26 | secondary: '#a7a7a7', 27 | }, 28 | general: { 29 | primary: '#6e6e6e', 30 | secondary: '#a7a7a7', 31 | tertiary: '#3d3d3d', 32 | disabled: '#262626', 33 | dark: '#0f0f0f', 34 | } 35 | }, 36 | }, 37 | }, 38 | typography: { 39 | fontFamily: 'Arial, sans-serif', 40 | }, 41 | }); 42 | 43 | export default theme; -------------------------------------------------------------------------------- /src/ui/TabList.jsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonGroup } from "@mui/joy"; 2 | import PropTypes from "prop-types"; 3 | 4 | function TabList({ terminals, activeTab, setActiveTab, closeTab, toggleSplit, splitTabIds, theme }) { 5 | const isSplitScreenActive = splitTabIds.length > 0; 6 | 7 | return ( 8 |
9 | {terminals.map((terminal, index) => { 10 | const isActive = terminal.id === activeTab; 11 | const isSplit = splitTabIds.includes(terminal.id); 12 | const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || (splitTabIds.length >= 3 && !isSplit); 13 | 14 | return ( 15 |
16 | 17 | {/* Set Active Tab Button */} 18 | 36 | {/* Split Screen Button */} 37 | 54 | {/* Close Tab Button */} 55 | 71 | 72 |
73 | ); 74 | })} 75 |
76 | ); 77 | } 78 | 79 | TabList.propTypes = { 80 | terminals: PropTypes.array.isRequired, 81 | activeTab: PropTypes.any, 82 | setActiveTab: PropTypes.func.isRequired, 83 | closeTab: PropTypes.func.isRequired, 84 | toggleSplit: PropTypes.func.isRequired, 85 | splitTabIds: PropTypes.array.isRequired, 86 | theme: PropTypes.object.isRequired, 87 | }; 88 | 89 | export default TabList; -------------------------------------------------------------------------------- /src/utils/cssLoader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for loading CSS dynamically 3 | */ 4 | 5 | /** 6 | * Loads CSS from a string and adds it to the document 7 | * @param {string} css - The CSS string to load 8 | * @returns {HTMLStyleElement} The created style element 9 | */ 10 | export const loadCSSFromString = (css) => { 11 | const style = document.createElement('style'); 12 | 13 | style.textContent = css; 14 | 15 | document.head.appendChild(style); 16 | 17 | return style; 18 | }; 19 | 20 | /** 21 | * Loads CSS from a URL and adds it to the document 22 | * @param {string} url - The URL of the CSS file to load 23 | * @returns {HTMLLinkElement} The created link element 24 | */ 25 | export const loadCSSFromURL = (url) => { 26 | const link = document.createElement('link'); 27 | 28 | link.rel = 'stylesheet'; 29 | link.type = 'text/css'; 30 | link.href = url; 31 | 32 | document.head.appendChild(link); 33 | 34 | return link; 35 | }; 36 | 37 | /** 38 | * Removes a style or link element created by loadCSSFromString or loadCSSFromURL 39 | * @param {HTMLElement} element - The element to remove 40 | */ 41 | export const removeCSS = (element) => { 42 | if (element && element.parentNode) { 43 | element.parentNode.removeChild(element); 44 | } 45 | }; 46 | 47 | export default { 48 | loadCSSFromString, 49 | loadCSSFromURL, 50 | removeCSS 51 | }; -------------------------------------------------------------------------------- /src/utils/fontLoader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Font utility for terminal fonts - simplified version 3 | */ 4 | 5 | export const fontDisplayNames = { 6 | nerdFont: 'Nerd Font (Hack)' 7 | }; 8 | 9 | /** 10 | * Get a formatted font family string for use in CSS 11 | * @param {string} fontName - The internal font name 12 | * @returns {string} CSS-ready font-family string 13 | */ 14 | export const getFormattedFontFamily = (fontName) => { 15 | return '"Hack Nerd Font", "Symbols Nerd Font", monospace'; 16 | }; 17 | 18 | /** 19 | * Actively preload all fonts to ensure they're available 20 | * @returns {Promise} Promise that resolves when fonts are loaded 21 | */ 22 | export const preloadAllFonts = () => { 23 | const styleElement = document.createElement('style'); 24 | styleElement.innerHTML = ` 25 | .font-preload-container { 26 | position: absolute; 27 | visibility: hidden; 28 | pointer-events: none; 29 | width: 0; 30 | height: 0; 31 | overflow: hidden; 32 | } 33 | `; 34 | document.head.appendChild(styleElement); 35 | 36 | const container = document.createElement('div'); 37 | container.className = 'font-preload-container'; 38 | 39 | const el = document.createElement('div'); 40 | el.className = 'terminal-nerd-font'; 41 | el.textContent = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789'; 42 | container.appendChild(el); 43 | 44 | document.body.appendChild(container); 45 | 46 | container.getBoundingClientRect(); 47 | 48 | return new Promise(resolve => { 49 | setTimeout(() => { 50 | resolve(); 51 | }, 1000); 52 | }); 53 | }; 54 | 55 | /** 56 | * Load a specific font 57 | * @param {string} fontName - The name of the font to load 58 | * @returns {Promise} Promise that resolves when the font is loaded 59 | */ 60 | export const loadFont = (fontName) => { 61 | const testEl = document.createElement('div'); 62 | testEl.style.position = 'absolute'; 63 | testEl.style.visibility = 'hidden'; 64 | testEl.style.pointerEvents = 'none'; 65 | testEl.className = 'terminal-nerd-font'; 66 | testEl.textContent = 'Font Load Test'; 67 | document.body.appendChild(testEl); 68 | 69 | return new Promise(resolve => { 70 | setTimeout(() => { 71 | document.body.removeChild(testEl); 72 | resolve(); 73 | }, 100); 74 | }); 75 | }; 76 | 77 | export default { 78 | loadFont, 79 | preloadAllFonts, 80 | getFormattedFontFamily, 81 | fontDisplayNames 82 | }; -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from "@tailwindcss/vite"; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tailwindcss()], 8 | 9 | server: { 10 | watch: { 11 | ignored: ["**/docker/**"], 12 | }, 13 | }, 14 | }) --------------------------------------------------------------------------------