├── .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 | 
3 | 
4 | 
5 |
6 | #### Top Technologies
7 | [](#)
8 | [](#)
9 | [](#)
10 | [](#)
11 | [](#)
12 | [](#)
13 | [](#)
14 | [](#)
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | If you would like, you can support the project here!\
24 | [](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 | 
59 | 
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 |
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 |
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 | }
63 | sx={{
64 | backgroundColor: "#c53030",
65 | color: "white",
66 | "&:hover": {
67 | backgroundColor: "#9b2c2c",
68 | },
69 | height: "40px",
70 | border: "1px solid #9b2c2c",
71 | }}
72 | >
73 | Delete
74 |
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 |
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 |
}
74 | sx={{
75 | backgroundColor: theme.palette.general.tertiary,
76 | color: "white",
77 | "&:hover": {
78 | backgroundColor: theme.palette.general.secondary,
79 | },
80 | height: "40px",
81 | border: `1px solid ${theme.palette.general.secondary}`,
82 | }}
83 | >
84 | Logout
85 |
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 |
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 | })
--------------------------------------------------------------------------------