diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6767b86c..9ed4e074 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -65,7 +65,7 @@ jobs: - name: Notify via ntfy run: | curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \ - https://ntfy.karmaashomepage.online/termix-build + https://ntfy.karmaa.site/termix-build - name: Delete all untagged image versions uses: quartx-analytics/ghcr-cleaner@v1 diff --git a/.gitignore b/.gitignore index 56bf7b8a..e1d6051b 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,8 @@ typings/ .dotnet/ # .local -.local/ \ No newline at end of file +.local/ +/docker/docker-compose.yml +/src/data/ +/docker/mongodb/ +/docker/docker-compose.yml diff --git a/README.md b/README.md index e629d245..cdf5d855 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,11 @@ [![Javascript Badge](https://img.shields.io/badge/-Javascript-F0DB4F?style=flat-square&labelColor=black&logo=javascript&logoColor=F0DB4F)](#) [![Nodejs Badge](https://img.shields.io/badge/-Nodejs-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#) [![HTML Badge](https://img.shields.io/badge/-HTML-E34F26?style=flat-square&labelColor=black&logo=html5&logoColor=E34F26)](#) -[![CSS Badge](https://img.shields.io/badge/-CSS-1572B6?style=flat-square&labelColor=black&logo=css3&logoColor=1572B6)](#) +[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#) [![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#) +[![MongoDB Badge](https://img.shields.io/badge/-MongoDB-47A248?style=flat-square&labelColor=black&logo=mongodb&logoColor=47A248)](#) +[![MUI Joy Badge](https://img.shields.io/badge/-MUI%20Joy-007FFF?style=flat-square&labelColor=black&logo=mui&logoColor=007FFF)](#) +

@@ -29,26 +32,31 @@ Termix is an open-source forever free self-hosted SSH (other protocols planned, # Features - SSH - Split Screen (Up to 4) & Tab System +- User Authentication +- Save Hosts (and easily view, connect, and manage them) # Planned Features -- Database to Store Connection Details - VNC - RDP - SFTP (build in file transfer) - ChatGPT/Ollama Integration (for commands) -- Login Screen -- User Management - Apps (like notes, AI, etc) +- Terminal Themes +- User Management (roles, permissions, etc.) +- SSH Tunneling +- More Authentication Methods +- More Security Features (like 2FA, etc.) # Installation 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). # Support -If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo. If you would like to support me financially, you can on [Paypal](https://paypal.me/LukeGustafson803) +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). # Show-off ![Demo Image](repo-images/DemoImage1.png) +![Demo Image](repo-images/DemoImage2.png) # License Distributed under the MIT license. See LICENSE for more information. diff --git a/docker/Dockerfile b/docker/Dockerfile index f89529c1..8db6b186 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build frontend -FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-builder +FROM --platform=$BUILDPLATFORM node:18 AS frontend-builder WORKDIR /app COPY package*.json ./ RUN npm install @@ -7,31 +7,53 @@ COPY . . RUN npm run build # Stage 2: Build backend -FROM --platform=$BUILDPLATFORM node:18-alpine AS backend-builder +FROM --platform=$BUILDPLATFORM node:18 AS backend-builder WORKDIR /app COPY package*.json ./ RUN npm install COPY src/backend/ ./src/backend/ # Stage 3: Final production image -FROM node:18-alpine -RUN apk add --no-cache nginx +FROM mongo:5 +# Install Node.js +RUN apt-get update && apt-get install -y \ + curl \ + nginx \ + python3 \ + build-essential \ + && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && apt-get install -y nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* # Configure nginx COPY docker/nginx.conf /etc/nginx/nginx.conf COPY --from=frontend-builder /app/dist /usr/share/nginx/html -# Copy backend -COPY --from=backend-builder /app/node_modules ./node_modules +# Setup backend +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev COPY --from=backend-builder /app/src/backend ./src/backend -# Create separate directories for nginx and node -RUN mkdir -p /var/log/nginx && \ +# Create directories for MongoDB and nginx +RUN mkdir -p /data/db && \ + mkdir -p /var/log/nginx && \ mkdir -p /var/lib/nginx && \ - chown -R nginx:nginx /var/log/nginx /var/lib/nginx + mkdir -p /var/log/mongodb && \ + chown -R mongodb:mongodb /data/db /var/log/mongodb && \ + chown -R www-data:www-data /var/log/nginx /var/lib/nginx + +# Set environment variables +ENV MONGO_URL=mongodb://localhost:27017/termix \ + MONGODB_DATA_DIR=/data/db \ + MONGODB_LOG_DIR=/var/log/mongodb + +# Create volume for MongoDB data +VOLUME ["/data/db"] # Expose ports -EXPOSE 8080 8081 +EXPOSE 8080 8081 8082 27017 # Use a entrypoint script to run all services COPY docker/entrypoint.sh /entrypoint.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 51b3b8cb..f372ddc8 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,10 +1,32 @@ -#!/bin/sh +#!/bin/bash +set -e -# Start NGINX in background -nginx -g "daemon off;" & +# Start MongoDB +echo "Starting MongoDB..." +mongod --fork --dbpath $MONGODB_DATA_DIR --logpath $MONGODB_LOG_DIR/mongodb.log -# Start Node.js backend -node src/backend/server.cjs +# Wait for MongoDB to be ready +echo "Waiting for MongoDB to start..." +until mongosh --eval "print(\"waited for connection\")" > /dev/null 2>&1; do + sleep 0.5 +done +echo "MongoDB has started" -# Keep container running -wait \ No newline at end of file +# Start nginx +echo "Starting nginx..." +nginx + +# Change to app directory +cd /app + +# Start the SSH service +echo "Starting SSH service..." +node src/backend/ssh.cjs & + +# Start the database service +echo "Starting database service..." +node src/backend/database.cjs & + +# Keep the container running and show MongoDB logs +echo "All services started. Tailing MongoDB logs..." +tail -f $MONGODB_LOG_DIR/mongodb.log \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf index 65c433f3..9ba02608 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -19,18 +19,32 @@ http { index index.html index.htm; } - # Proxy IO requests - location /socket.io/ { + # Proxy SSH socket requests + location /ssh.io/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; + proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; - # Timeout settings - proxy_read_timeout 86400s; - proxy_send_timeout 86400s; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Proxy MongoDB socket requests + location /database.io/ { + proxy_pass http://127.0.0.1:8082; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } # Error pages diff --git a/package-lock.json b/package-lock.json index c1084feb..7615c7d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/inter": "^5.1.1", + "@mui/icons-material": "^6.4.7", "@mui/joy": "^5.0.0-beta.51", "@tailwindcss/vite": "^4.0.8", "@tiptap/extension-link": "^2.11.5", @@ -19,12 +20,16 @@ "@tiptap/starter-kit": "^2.11.5", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", + "bcrypt": "^5.1.1", "cors": "^2.8.5", + "crypto": "^1.0.1", "dayjs": "^1.11.13", + "dotenv": "^16.4.7", "embla-carousel-react": "^7.1.0", "express": "^4.21.2", "is-stream": "^4.0.1", "make-dir": "^5.0.0", + "mongoose": "^8.12.1", "node-ssh": "^13.2.0", "prop-types": "^15.8.1", "react": "^18.3.1", @@ -1200,6 +1205,80 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.0.tgz", + "integrity": "sha512-+ywrb0AqkfaYuhHs6LxKWgqbh3I72EpEgESCw37o+9qPx9WTCkgDm2B+eMrwehGtHBWHFU4GXvnSCNiFhhausg==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@mui/base": { "version": "5.0.0-beta.40-0", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40-0.tgz", @@ -1243,6 +1322,32 @@ "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/icons-material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.7.tgz", + "integrity": "sha512-Rk8cs9ufQoLBw582Rdqq7fnSXXZTqhYRbpe1Y5SAz9lJKZP3CIdrj0PfG8HJLGw1hrsHFN/rkkm70IDzhJsG1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.4.7", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/joy": { "version": "5.0.0-beta.51", "resolved": "https://registry.npmjs.org/@mui/joy/-/joy-5.0.0-beta.51.tgz", @@ -1284,6 +1389,209 @@ } } }, + "node_modules/@mui/material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.7.tgz", + "integrity": "sha512-K65StXUeGAtFJ4ikvHKtmDCO5Ab7g0FZUu2J5VpoKD+O6Y3CjLYzRi+TMlI3kaL4CL158+FccMoOd/eaddmeRQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.4.7", + "@mui/system": "^6.4.7", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.4.7", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/core-downloads-tracker": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.7.tgz", + "integrity": "sha512-XjJrKFNt9zAKvcnoIIBquXyFyhfrHYuttqMsoDS7lM7VwufYG4fAPw4kINjBFg++fqXM2BNAuWR9J7XVIuKIKg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/material/node_modules/@mui/private-theming": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.6.tgz", + "integrity": "sha512-T5FxdPzCELuOrhpA2g4Pi6241HAxRwZudzAuL9vBvniuB5YU82HCmrARw32AuCiyTfWzbrYGGpZ4zyeqqp9RvQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.6", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/styled-engine": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.6.tgz", + "integrity": "sha512-vSWYc9ZLX46be5gP+FCzWVn5rvDr4cXC5JBZwSIkYk9xbC7GeV+0kCvB8Q6XLFQJy+a62bbqtmdwS4Ghi9NBlQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/system": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.7.tgz", + "integrity": "sha512-7wwc4++Ak6tGIooEVA9AY7FhH2p9fvBMORT4vNLMAysH3Yus/9B9RYMbrn3ANgsOyvT3Z7nE+SP8/+3FimQmcg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.6", + "@mui/styled-engine": "^6.4.6", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/utils": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.6.tgz", + "integrity": "sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT", + "peer": true + }, "node_modules/@mui/private-theming": { "version": "5.16.14", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.14.tgz", @@ -2505,7 +2813,6 @@ "version": "18.3.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2522,12 +2829,37 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", @@ -2563,6 +2895,12 @@ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "license": "MIT" }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2599,6 +2937,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2616,6 +2966,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2632,6 +2991,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2854,7 +3233,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64id": { @@ -2866,6 +3244,20 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -2918,7 +3310,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2958,6 +3349,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", + "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buildcheck": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", @@ -3071,6 +3471,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3100,13 +3509,27 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -3223,6 +3646,13 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -3476,6 +3906,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3530,6 +3966,18 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3575,6 +4023,12 @@ "react": "^16.8.0 || ^17.0.1 || ^18.0.0" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4378,6 +4832,42 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4432,6 +4922,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4497,6 +5008,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4638,6 +5170,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4675,6 +5213,19 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4723,6 +5274,17 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4928,6 +5490,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -5275,6 +5846,15 @@ "node": ">=4.0" } }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5652,6 +6232,12 @@ "node": ">= 0.6" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -5707,7 +6293,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -5716,6 +6301,157 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongodb": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz", + "integrity": "sha512-kMEHNo0F3P6QKDq17zcDuPeaywK/YaJVCEQRzPF3TOM/Bl9MFg64YE5Tu7ifj37qZJMhwU1tl2Ioivws5gRG5Q==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.3", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.12.1.tgz", + "integrity": "sha512-UW22y8QFVYmrb36hm8cGncfn4ARc/XsYWQwRTaj0gxtQk1rDuhzDO1eBantS+hTTatfAIS96LlRCJrcNHvW5+Q==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.3", + "kareem": "2.6.3", + "mongodb": "~6.14.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5763,6 +6499,54 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -5814,6 +6598,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5931,6 +6743,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6054,6 +6875,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6362,7 +7192,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6488,6 +7317,20 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/recharts": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", @@ -6603,6 +7446,22 @@ "node": ">=4" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.32.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.32.1.tgz", @@ -6830,6 +7689,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6986,6 +7851,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/socket.io": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", @@ -7128,6 +8005,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/ssh2": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", @@ -7154,6 +8040,29 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7252,6 +8161,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7311,6 +8232,29 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -7335,6 +8279,18 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -7535,6 +8491,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -7652,6 +8614,28 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", + "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7756,6 +8740,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7766,6 +8759,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", diff --git a/package.json b/package.json index dbc72f7a..0ab6013a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/inter": "^5.1.1", + "@mui/icons-material": "^6.4.7", "@mui/joy": "^5.0.0-beta.51", "@tailwindcss/vite": "^4.0.8", "@tiptap/extension-link": "^2.11.5", @@ -21,12 +22,16 @@ "@tiptap/starter-kit": "^2.11.5", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", + "bcrypt": "^5.1.1", "cors": "^2.8.5", + "crypto": "^1.0.1", "dayjs": "^1.11.13", + "dotenv": "^16.4.7", "embla-carousel-react": "^7.1.0", "express": "^4.21.2", "is-stream": "^4.0.1", "make-dir": "^5.0.0", + "mongoose": "^8.12.1", "node-ssh": "^13.2.0", "prop-types": "^15.8.1", "react": "^18.3.1", diff --git a/repo-images/DemoImage1.png b/repo-images/DemoImage1.png index 68f6e262..87d3e82e 100644 Binary files a/repo-images/DemoImage1.png and b/repo-images/DemoImage1.png differ diff --git a/repo-images/DemoImage2.png b/repo-images/DemoImage2.png new file mode 100644 index 00000000..6fb8a048 Binary files /dev/null and b/repo-images/DemoImage2.png differ diff --git a/src/AddHostModal.jsx b/src/AddHostModal.jsx deleted file mode 100644 index d73b78a3..00000000 --- a/src/AddHostModal.jsx +++ /dev/null @@ -1,200 +0,0 @@ -import PropTypes from 'prop-types'; -import { CssVarsProvider } from '@mui/joy/styles'; -import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog, Select, Option } from '@mui/joy'; -import theme from './theme'; - -const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidden }) => { - const handleFileChange = (e) => { - const file = e.target.files[0]; - if (file) { - if (file.name.endsWith('.rsa') || file.name.endsWith('.key') || file.name.endsWith('.pem') || file.name.endsWith('.der') || file.name.endsWith('.p8') || file.name.endsWith('.ssh')) { - const reader = new FileReader(); - reader.onload = (event) => { - setForm({ ...form, rsaKey: event.target.result }); - }; - reader.readAsText(file); - } else { - alert("Please upload a valid RSA private key file."); - } - } - }; - - const isFormValid = () => { - if (form.authMethod === 'Select Auth') return false; - if (!form.ip || !form.user || !form.port) return false; - if (form.authMethod === 'rsaKey' && !form.rsaKey) return false; - if (form.authMethod === 'password' && !form.password) return false; - return true; - }; - - return ( - - setIsAddHostHidden(true)}> - - Add Host - -

{ - event.preventDefault(); - if (isFormValid()) handleAddHost(); - }} - > - - - Host Name - setForm({ ...form, name: e.target.value })} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - Host IP - setForm({ ...form, ip: e.target.value })} - required - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - Host User - setForm({ ...form, user: e.target.value })} - required - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - Authentication Method - - - {form.authMethod === 'password' && ( - - Host Password - setForm({ ...form, password: e.target.value })} - required - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - )} - {form.authMethod === 'rsaKey' && ( - - RSA Key - - - )} - 65535}> - Host Port - setForm({ ...form, port: e.target.value })} - min={1} - max={65535} - required - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - -
- - - - - ); -}; - -AddHostModal.propTypes = { - isHidden: PropTypes.bool.isRequired, - form: PropTypes.shape({ - name: PropTypes.string, - ip: PropTypes.string.isRequired, - user: PropTypes.string.isRequired, - password: PropTypes.string, - rsaKey: PropTypes.string, - port: PropTypes.number.isRequired, - authMethod: PropTypes.string.isRequired, - }).isRequired, - setForm: PropTypes.func.isRequired, - handleAddHost: PropTypes.func.isRequired, - setIsAddHostHidden: PropTypes.func.isRequired, -}; - -export default AddHostModal; \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 6c1a6474..cd6caadf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,30 +1,75 @@ -import { useState, useEffect } from "react"; -import { NewTerminal } from "./Terminal.jsx"; -import AddHostModal from "./AddHostModal.jsx"; +import { useState, useEffect, useRef } from "react"; +import { NewTerminal } from "./apps/ssh/Terminal.jsx"; +import { User } from "./apps/user/User.jsx"; +import AddHostModal from "./modals/AddHostModal.jsx"; +import LoginUserModal from "./modals/LoginUserModal.jsx"; import { Button } from "@mui/joy"; import { CssVarsProvider } from "@mui/joy"; import theme from "./theme"; -import TabList from "./TabList.jsx"; -import Launchpad from "./Launchpad.jsx"; -import { Debounce } from './Utils'; +import TabList from "./ui/TabList.jsx"; +import Launchpad from "./apps/Launchpad.jsx"; +import { Debounce } from './other/Utils.jsx'; import TermixIcon from "./images/termix_icon.png"; import RocketIcon from './images/launchpad_rocket.png'; +import ProfileIcon from './images/profile_icon.png'; +import CreateUserModal from "./modals/CreateUserModal.jsx"; +import ProfileModal from "./modals/ProfileModal.jsx"; +import ErrorModal from "./modals/ErrorModal.jsx"; +import EditHostModal from "./modals/EditHostModal.jsx"; +import NoAuthenticationModal from "./modals/NoAuthenticationModal.jsx"; function App() { const [isAddHostHidden, setIsAddHostHidden] = useState(true); + const [isLoginUserHidden, setIsLoginUserHidden] = useState(true); + const [isCreateUserHidden, setIsCreateUserHidden] = useState(true); + const [isProfileHidden, setIsProfileHidden] = useState(true); + const [isErrorHidden, setIsErrorHidden] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); const [terminals, setTerminals] = useState([]); + const userRef = useRef(null); const [activeTab, setActiveTab] = useState(null); const [nextId, setNextId] = useState(1); - const [form, setForm] = useState({ + const [addHostForm, setAddHostForm] = useState({ name: "", + folder: "", ip: "", user: "", password: "", port: 22, authMethod: "Select Auth", + rememberHost: false, + storePassword: true, + }); + const [editHostForm, setEditHostForm] = useState({ + name: "", + folder: "", + ip: "", + user: "", + password: "", + port: 22, + authMethod: "Select Auth", + rememberHost: true, + storePassword: true, + }); + const [isNoAuthHidden, setIsNoAuthHidden] = useState(true); + const [authForm, setAuthForm] = useState({ + password: "", + rsaKey: "", + }); + const [loginUserForm, setLoginUserForm] = useState({ + username: "", + password: "", + }); + const [createUserForm, setCreateUserForm] = useState({ + username: "", + password: "", }); const [isLaunchpadOpen, setIsLaunchpadOpen] = useState(false); const [splitTabIds, setSplitTabIds] = useState([]); + const [isEditHostHidden, setIsEditHostHidden] = useState(true); + const [currentHostConfig, setCurrentHostConfig] = useState(null); + const [isLoggingIn, setIsLoggingIn] = useState(true); + const [isEditing, setIsEditing] = useState(false); useEffect(() => { const handleKeyDown = (e) => { @@ -81,27 +126,347 @@ function App() { }); }, [splitTabIds]); + useEffect(() => { + const sessionToken = localStorage.getItem('sessionToken'); + let isComponentMounted = true; + let isLoginInProgress = false; + + if (userRef.current?.getUser()) { + setIsLoggingIn(false); + setIsLoginUserHidden(true); + return; + } + + if (!sessionToken) { + setIsLoggingIn(false); + setIsLoginUserHidden(false); + return; + } + + setIsLoggingIn(true); + let loginAttempts = 0; + const maxAttempts = 50; + let attemptLoginInterval; + + const loginTimeout = setTimeout(() => { + if (isComponentMounted) { + clearInterval(attemptLoginInterval); + if (!userRef.current?.getUser()) { + localStorage.removeItem('sessionToken'); + setIsLoginUserHidden(false); + setIsLoggingIn(false); + setErrorMessage('Login timed out. Please try again.'); + setIsErrorHidden(false); + } + } + }, 10000); + + const attemptLogin = () => { + if (!isComponentMounted || isLoginInProgress) return; + + if (loginAttempts >= maxAttempts || userRef.current?.getUser()) { + clearTimeout(loginTimeout); + clearInterval(attemptLoginInterval); + + if (!userRef.current?.getUser()) { + localStorage.removeItem('sessionToken'); + setIsLoginUserHidden(false); + setIsLoggingIn(false); + setErrorMessage('Login timed out. Please try again.'); + setIsErrorHidden(false); + } + return; + } + + if (userRef.current) { + isLoginInProgress = true; + userRef.current.loginUser({ + sessionToken, + onSuccess: () => { + if (isComponentMounted) { + clearTimeout(loginTimeout); + clearInterval(attemptLoginInterval); + setIsLoginUserHidden(true); + setIsLoggingIn(false); + setIsErrorHidden(true); + } + isLoginInProgress = false; + }, + onFailure: (error) => { + if (isComponentMounted) { + if (!userRef.current?.getUser()) { + clearTimeout(loginTimeout); + clearInterval(attemptLoginInterval); + localStorage.removeItem('sessionToken'); + setErrorMessage(`Auto-login failed: ${error}`); + setIsErrorHidden(false); + setIsLoginUserHidden(false); + setIsLoggingIn(false); + } + } + isLoginInProgress = false; + }, + }); + } + loginAttempts++; + }; + + attemptLoginInterval = setInterval(attemptLogin, 100); + attemptLogin(); + + return () => { + isComponentMounted = false; + clearTimeout(loginTimeout); + clearInterval(attemptLoginInterval); + }; + }, []); + const handleAddHost = () => { - if (form.ip && form.user && ((form.authMethod === 'password' && form.password) || (form.authMethod === 'rsaKey' && form.rsaKey)) && form.port) { - const newTerminal = { - id: nextId, - title: form.name || form.ip, - hostConfig: { - ip: form.ip, - user: form.user, - password: form.authMethod === 'password' ? form.password : undefined, - rsaKey: form.authMethod === 'rsaKey' ? form.rsaKey : undefined, - port: String(form.port), - }, - terminalRef: null, - }; - setTerminals([...terminals, newTerminal]); - setActiveTab(nextId); - setNextId(nextId + 1); + if (addHostForm.ip && addHostForm.user && addHostForm.port) { + if (!addHostForm.rememberHost) { + connectToHost(); + setIsAddHostHidden(true); + return; + } + + if (addHostForm.authMethod === 'Select Auth') { + alert("Please select an authentication method."); + return; + } + if (addHostForm.authMethod === 'password' && !addHostForm.password) { + setIsNoAuthHidden(false); + return; + } + if (addHostForm.authMethod === 'rsaKey' && !addHostForm.rsaKey) { + setIsNoAuthHidden(false); + return; + } + + connectToHost(); + if (!addHostForm.storePassword) { + addHostForm.password = ''; + } + handleSaveHost(); setIsAddHostHidden(true); - setForm({ name: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth" }); } else { - alert("Please fill out all fields."); + alert("Please fill out all required fields (IP, User, Port)."); + } + }; + + const connectToHost = () => { + const hostConfig = { + name: addHostForm.name || '', + folder: addHostForm.folder || '', + ip: addHostForm.ip, + user: addHostForm.user, + port: String(addHostForm.port), + password: addHostForm.rememberHost && addHostForm.authMethod === 'password' ? addHostForm.password : undefined, + rsaKey: addHostForm.rememberHost && addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined, + }; + + const newTerminal = { + id: nextId, + title: hostConfig.name || hostConfig.ip, + hostConfig, + terminalRef: null, + }; + setTerminals([...terminals, newTerminal]); + setActiveTab(nextId); + setNextId(nextId + 1); + setIsAddHostHidden(true); + setAddHostForm({ name: "", folder: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth", rememberHost: false, storePassword: true }); + } + + const handleAuthSubmit = (form) => { + const updatedTerminals = terminals.map((terminal) => { + if (terminal.id === activeTab) { + return { + ...terminal, + hostConfig: { + ...terminal.hostConfig, + password: form.password, + rsaKey: form.rsaKey + } + }; + } + return terminal; + }); + setTerminals(updatedTerminals); + setIsNoAuthHidden(true); + }; + + const connectToHostWithConfig = (hostConfig) => { + if (!hostConfig || typeof hostConfig !== 'object') { + return; + } + + if (!hostConfig.ip || !hostConfig.user) { + return; + } + + const cleanHostConfig = { + name: hostConfig.name || '', + folder: hostConfig.folder || '', + ip: hostConfig.ip.trim(), + user: hostConfig.user.trim(), + port: hostConfig.port || '22', + password: hostConfig.password?.trim(), + rsaKey: hostConfig.rsaKey?.trim(), + }; + + const newTerminal = { + id: nextId, + title: cleanHostConfig.name || cleanHostConfig.ip, + hostConfig: cleanHostConfig, + terminalRef: null, + }; + setTerminals([...terminals, newTerminal]); + setActiveTab(nextId); + setNextId(nextId + 1); + setIsLaunchpadOpen(false); + } + + const handleSaveHost = () => { + let hostConfig = { + name: addHostForm.name || addHostForm.ip, + folder: addHostForm.folder, + ip: addHostForm.ip, + user: addHostForm.user, + password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined, + rsaKey: addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined, + port: String(addHostForm.port), + } + if (userRef.current) { + userRef.current.saveHost({ + hostConfig, + }); + } + } + + const handleLoginUser = ({ username, password, sessionToken, onSuccess, onFailure }) => { + if (userRef.current) { + if (sessionToken) { + userRef.current.loginUser({ + sessionToken, + onSuccess: () => { + setIsLoginUserHidden(true); + setIsLoggingIn(false); + if (onSuccess) onSuccess(); + }, + onFailure: (error) => { + localStorage.removeItem('sessionToken'); + setIsLoginUserHidden(false); + setIsLoggingIn(false); + if (onFailure) onFailure(error); + }, + }); + } else { + userRef.current.loginUser({ + username, + password, + onSuccess: () => { + setIsLoginUserHidden(true); + setIsLoggingIn(false); + if (onSuccess) onSuccess(); + }, + onFailure: (error) => { + setIsLoginUserHidden(false); + setIsLoggingIn(false); + if (onFailure) onFailure(error); + }, + }); + } + } + }; + + const handleGuestLogin = () => { + if (userRef.current) { + userRef.current.loginAsGuest(); + } + } + + const handleCreateUser = ({ username, password, onSuccess, onFailure }) => { + if (userRef.current) { + userRef.current.createUser({ + username, + password, + onSuccess, + onFailure, + }); + } + }; + + const handleDeleteUser = ({ onSuccess, onFailure }) => { + if (userRef.current) { + userRef.current.deleteUser({ + onSuccess, + onFailure, + }); + } + }; + + const handleLogoutUser = () => { + if (userRef.current) { + userRef.current.logoutUser(); + window.location.reload(); + } + }; + + const getUser = () => { + if (userRef.current) { + return userRef.current.getUser(); + } + } + + const getHosts = () => { + if (userRef.current) { + return userRef.current.getAllHosts(); + } + } + + const deleteHost = (hostConfig) => { + if (userRef.current) { + userRef.current.deleteHost({ + hostId: hostConfig._id, + }); + } + }; + + const updateEditHostForm = (hostConfig) => { + if (hostConfig) { + setCurrentHostConfig(hostConfig); + setIsEditHostHidden(false); + } else { + console.error("hostConfig is null"); + } + }; + + const handleEditHost = async (oldConfig, newConfig = null) => { + try { + if (newConfig) { + if (isEditing) return; + setIsEditing(true); + + try { + await userRef.current.editHost({ + oldHostConfig: oldConfig, + newHostConfig: newConfig, + }); + + await new Promise(resolve => setTimeout(resolve, 3000)); + } finally { + setIsEditing(false); + setIsEditHostHidden(true); + } + return; + } + + updateEditHostForm(oldConfig); + } catch (error) { + console.error('Edit failed:', error); + setErrorMessage(`Edit failed: ${error}`); + setIsErrorHidden(false); + setIsEditing(false); } }; @@ -164,85 +529,236 @@ function App() { - {/* Launchpad Button */} - + {/* Action Buttons */} +
+ {/* Launchpad Button */} + - {/* Add Host Button */} - + {/* Add Host Button */} + + + {/* Profile Button */} + +
{/* Terminal Views */}
- {terminals.map((terminal) => ( -
- ( +
{ - if (ref && !terminal.terminalRef) { - setTerminals((prev) => - prev.map((t) => - t.id === terminal.id ? { ...t, terminalRef: ref } : t - ) - ); - } + className={`bg-neutral-800 rounded-lg overflow-hidden shadow-xl border-5 border-neutral-700 ${ + splitTabIds.includes(terminal.id) || activeTab === terminal.id ? "block" : "hidden" + } flex-1`} + style={{ + order: splitTabIds.includes(terminal.id) + ? splitTabIds.indexOf(terminal.id) + : 0, }} - /> + > + { + terminal.terminalRef = ref; + }} + /> +
+ )) + ) : ( +
+
+

Welcome to Termix

+

{isLoggingIn ? "Checking login status..." : "Please login to start managing your SSH connections"}

+
- ))} + )} +
-
- {/* Modals */} - - {isLaunchpadOpen && setIsLaunchpadOpen(false)} />} + {/* Modals */} + {userRef.current?.getUser() && ( + <> + + + + {isLaunchpadOpen && ( + setIsLaunchpadOpen(false)} + getHosts={getHosts} + connectToHost={connectToHostWithConfig} + isAddHostHidden={isAddHostHidden} + setIsAddHostHidden={setIsAddHostHidden} + isEditHostHidden={isEditHostHidden} + isErrorHidden={isErrorHidden} + deleteHost={deleteHost} + editHost={handleEditHost} + shareHost={(hostId, username) => userRef.current?.shareHost(hostId, username)} + userRef={userRef} + /> + )} + + )} + + + + + + + + {/* User component */} + { + setIsLoginUserHidden(true); + setIsLoggingIn(false); + setIsErrorHidden(true); + }} + onCreateSuccess={() => { + setIsCreateUserHidden(true); + handleLoginUser({ + username: createUserForm.username, + password: createUserForm.password, + onSuccess: () => { + setIsLoginUserHidden(true); + setIsLoggingIn(false); + setIsErrorHidden(true); + }, + onFailure: (error) => { + setErrorMessage(`Login failed: ${error}`); + setIsErrorHidden(false); + } + }); + }} + onDeleteSuccess={() => { + setIsProfileHidden(true); + window.location.reload(); + }} + onFailure={(error) => { + setErrorMessage(`Action failed: ${error}`); + setIsErrorHidden(false); + setIsLoggingIn(false); + }} + /> + ); diff --git a/src/Launchpad.jsx b/src/Launchpad.jsx deleted file mode 100644 index a59d5595..00000000 --- a/src/Launchpad.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import PropTypes from 'prop-types'; -import { useEffect, useRef } from 'react'; -import { CssVarsProvider } from '@mui/joy/styles'; -import { Button } from '@mui/joy'; -import theme from './theme'; - -function Launchpad({ onClose }) { - const launchpadRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event) => { - if (launchpadRef.current && !launchpadRef.current.contains(event.target)) { - onClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [onClose]); - - return ( - -
-
-
-

Launchpad

-

A one-stop shop for adding hosts, apps (AI, notes, etc.), and all new features to come! Coming to you in a future update. Stay tuned!

-

- Can also be opened using Ctrl + L -

- -
-
-
-
- ); -} - -Launchpad.propTypes = { - onClose: PropTypes.func.isRequired, -}; - -export default Launchpad; \ No newline at end of file diff --git a/src/apps/Launchpad.jsx b/src/apps/Launchpad.jsx new file mode 100644 index 00000000..c334c6b6 --- /dev/null +++ b/src/apps/Launchpad.jsx @@ -0,0 +1,216 @@ +import PropTypes from 'prop-types'; +import { useEffect, useRef, useState } from 'react'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { Button } from '@mui/joy'; +import HostViewerIcon from '../images/host_viewer_icon.png'; +import theme from '../theme.js'; +import HostViewer from './ssh/HostViewer.jsx'; + +function Launchpad({ + onClose, + getHosts, + connectToHost, + isAddHostHidden, + setIsAddHostHidden, + isEditHostHidden, + isErrorHidden, + deleteHost, + editHost, + shareHost, + userRef, +}) { + const launchpadRef = useRef(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [activeApp, setActiveApp] = useState('hostViewer'); + const [isAnyModalOpen, setIsAnyModalOpen] = useState(false); + + useEffect(() => { + const handleClickOutside = (event) => { + if ( + launchpadRef.current && + !launchpadRef.current.contains(event.target) && + isAddHostHidden && + isEditHostHidden && + isErrorHidden && + !isAnyModalOpen + ) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden, isAnyModalOpen]); + + const handleModalOpen = () => { + setIsAnyModalOpen(true); + }; + + const handleModalClose = () => { + setIsAnyModalOpen(false); + }; + + return ( + +
+
+ {/* Sidebar */} +
+ {/* Sidebar Toggle Button */} + + + {/* HostViewer Button */} + +
+ + {/* Main Content */} +
+ {activeApp === 'hostViewer' && ( + { + if (!hostConfig || typeof hostConfig !== 'object') { + return; + } + if (!hostConfig.ip || !hostConfig.user) { + return; + } + connectToHost(hostConfig); + }} + setIsAddHostHidden={setIsAddHostHidden} + deleteHost={deleteHost} + editHost={editHost} + openEditPanel={editHost} + shareHost={shareHost} + onModalOpen={handleModalOpen} + onModalClose={handleModalClose} + userRef={userRef} + /> + )} +
+
+
+
+ ); +} + +Launchpad.propTypes = { + onClose: PropTypes.func.isRequired, + getHosts: PropTypes.func.isRequired, + connectToHost: PropTypes.func.isRequired, + isAddHostHidden: PropTypes.bool.isRequired, + setIsAddHostHidden: PropTypes.func.isRequired, + isEditHostHidden: PropTypes.bool.isRequired, + isErrorHidden: PropTypes.bool.isRequired, + deleteHost: PropTypes.func.isRequired, + editHost: PropTypes.func.isRequired, + shareHost: PropTypes.func.isRequired, + userRef: PropTypes.object.isRequired, +}; + +export default Launchpad; \ No newline at end of file diff --git a/src/apps/ssh/HostViewer.jsx b/src/apps/ssh/HostViewer.jsx new file mode 100644 index 00000000..de84fbea --- /dev/null +++ b/src/apps/ssh/HostViewer.jsx @@ -0,0 +1,423 @@ +import PropTypes from "prop-types"; +import { useState, useEffect, useRef } from "react"; +import { Button, Input } from "@mui/joy"; +import ShareHostModal from "../../modals/ShareHostModal"; + +function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, editHost, openEditPanel, shareHost, onModalOpen, onModalClose, userRef }) { + const [hosts, setHosts] = useState([]); + const [filteredHosts, setFilteredHosts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [collapsedFolders, setCollapsedFolders] = useState(new Set()); + const [draggedHost, setDraggedHost] = useState(null); + const [isDraggingOver, setIsDraggingOver] = useState(null); + const isMounted = useRef(true); + const [isDeleting, setIsDeleting] = useState(false); + const [isShareModalHidden, setIsShareModalHidden] = useState(true); + const [selectedHostForShare, setSelectedHostForShare] = useState(null); + + const fetchHosts = async () => { + try { + const savedHosts = await getHosts(); + if (isMounted.current) { + setHosts(savedHosts || []); + setFilteredHosts(savedHosts || []); + setIsLoading(false); + } + } catch (error) { + console.error("Host fetch failed:", error); + if (isMounted.current) { + setHosts([]); + setFilteredHosts([]); + setIsLoading(false); + } + } + }; + + useEffect(() => { + isMounted.current = true; + fetchHosts(); + + const intervalId = setInterval(() => { + fetchHosts(); + }, 2000); + + return () => { + isMounted.current = false; + clearInterval(intervalId); + }; + }, []); + + useEffect(() => { + const filtered = hosts.filter((hostWrapper) => { + const hostConfig = hostWrapper.config || {}; + return hostConfig.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + hostConfig.ip?.toLowerCase().includes(searchTerm.toLowerCase()) || + hostConfig.folder?.toLowerCase().includes(searchTerm.toLowerCase()); + }); + setFilteredHosts(filtered); + }, [searchTerm, hosts]); + + useEffect(() => { + if (!isShareModalHidden) { + onModalOpen(); + } else { + onModalClose(); + } + }, [isShareModalHidden, onModalOpen, onModalClose]); + + const toggleFolder = (folderName) => { + setCollapsedFolders(prev => { + const newSet = new Set(prev); + if (newSet.has(folderName)) { + newSet.delete(folderName); + } else { + newSet.add(folderName); + } + return newSet; + }); + }; + + const groupHostsByFolder = (hosts) => { + const grouped = {}; + const noFolder = []; + + const sortedHosts = [...hosts].sort((a, b) => { + const nameA = (a.config?.name || a.config?.ip || '').toLowerCase(); + const nameB = (b.config?.name || b.config?.ip || '').toLowerCase(); + return nameA.localeCompare(nameB); + }); + + sortedHosts.forEach(host => { + const folder = host.config?.folder; + if (folder) { + if (!grouped[folder]) { + grouped[folder] = []; + } + grouped[folder].push(host); + } else { + noFolder.push(host); + } + }); + + const sortedFolders = Object.keys(grouped).sort((a, b) => a.localeCompare(b)); + + return { grouped, sortedFolders, noFolder }; + }; + + const handleDragStart = (e, host) => { + setDraggedHost(host); + e.dataTransfer.setData('text/plain', ''); + }; + + const handleDragOver = (e, folderName) => { + e.preventDefault(); + setIsDraggingOver(folderName); + }; + + const handleDragLeave = () => { + setIsDraggingOver(null); + }; + + const handleDrop = async (e, targetFolder) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(null); + + if (!draggedHost) return; + + if (draggedHost.config.folder === targetFolder) return; + + const newConfig = { + ...draggedHost.config, + folder: targetFolder + }; + + try { + await editHost(draggedHost.config, newConfig); + await fetchHosts(); + } catch (error) { + console.error('Failed to update folder:', error); + } + + setDraggedHost(null); + }; + + const handleDropOnNoFolder = async (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(null); + + if (!draggedHost || !draggedHost.config.folder) return; + + const newConfig = { + ...draggedHost.config, + folder: null + }; + + try { + await editHost(draggedHost.config, newConfig); + await fetchHosts(); + } catch (error) { + console.error('Failed to remove from folder:', error); + } + + setDraggedHost(null); + }; + + const handleDelete = async (e, hostWrapper) => { + e.stopPropagation(); + if (isDeleting) return; + + setIsDeleting(true); + try { + const isOwner = hostWrapper.createdBy?._id === userRef.current?.getUser()?.id; + if (isOwner) { + await deleteHost({ _id: hostWrapper._id }); + } else { + await userRef.current.removeShare(hostWrapper._id); + } + await new Promise(resolve => setTimeout(resolve, 500)); + await fetchHosts(); + } catch (error) { + console.error('Failed to delete/remove host:', error); + } finally { + setIsDeleting(false); + } + }; + + const handleShare = async (hostId, username) => { + try { + await shareHost(hostId, username); + await fetchHosts(); + } catch (error) { + console.error('Failed to share host:', error); + } + }; + + const renderHostItem = (hostWrapper) => { + const hostConfig = hostWrapper.config || {}; + const isOwner = hostWrapper.createdBy?._id === userRef.current?.getUser()?.id; + + if (!hostConfig) { + return null; + } + + return ( +
isOwner && handleDragStart(e, hostWrapper)} + onDragEnd={() => setDraggedHost(null)} + > +
+
⋮⋮
+
+
+

{hostConfig.name || hostConfig.ip}

+ {!isOwner && ( + + Shared by {hostWrapper.createdBy?.username} + + )} +
+

+ {hostConfig.user ? `${hostConfig.user}@${hostConfig.ip}` : `${hostConfig.ip}:${hostConfig.port}`} +

+
+
+
+ + {isOwner && ( + <> + + + + + )} + {!isOwner && ( + + )} +
+
+ ); + }; + + return ( +
+
+ setSearchTerm(e.target.value)} + sx={{ + flex: 1, + backgroundColor: "#6e6e6e", + color: "#fff", + "&::placeholder": { color: "#ccc" }, + }} + /> + +
+
+ {isLoading ? ( +

Loading hosts...

+ ) : filteredHosts.length > 0 ? ( +
+ {(() => { + const { grouped, sortedFolders, noFolder } = groupHostsByFolder(filteredHosts); + + return ( + <> + {/* Render hosts without folders first */} +
handleDragOver(e, 'no-folder')} + onDragLeave={handleDragLeave} + onDrop={handleDropOnNoFolder} + > + {noFolder.map((host) => renderHostItem(host))} +
+ + {/* Render folders and their hosts */} + {sortedFolders.map((folderName) => ( +
+
toggleFolder(folderName)} + onDragOver={(e) => handleDragOver(e, folderName)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, folderName)} + > + + ▼ + + {folderName} + + ({grouped[folderName].length}) + +
+ {!collapsedFolders.has(folderName) && ( +
+ {grouped[folderName].map((host) => renderHostItem(host))} +
+ )} +
+ ))} + + ); + })()} +
+ ) : ( +

No hosts available...

+ )} +
+ +
+ ); +} + +HostViewer.propTypes = { + getHosts: PropTypes.func.isRequired, + connectToHost: PropTypes.func.isRequired, + setIsAddHostHidden: PropTypes.func.isRequired, + deleteHost: PropTypes.func.isRequired, + editHost: PropTypes.func.isRequired, + openEditPanel: PropTypes.func.isRequired, + shareHost: PropTypes.func.isRequired, + onModalOpen: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + userRef: PropTypes.object.isRequired, +}; + +export default HostViewer; \ No newline at end of file diff --git a/src/Terminal.jsx b/src/apps/ssh/Terminal.jsx similarity index 62% rename from src/Terminal.jsx rename to src/apps/ssh/Terminal.jsx index 11a259e4..b50f90f3 100644 --- a/src/Terminal.jsx +++ b/src/apps/ssh/Terminal.jsx @@ -4,9 +4,9 @@ import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import io from "socket.io-client"; import PropTypes from "prop-types"; -import theme from "./theme"; +import theme from "../../theme.js"; -export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => { +export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidden }, ref) => { const terminalRef = useRef(null); const socketRef = useRef(null); const fitAddon = useRef(new FitAddon()); @@ -55,30 +55,61 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => { terminalInstance.current.loadAddon(fitAddon.current); terminalInstance.current.open(terminalRef.current); - setTimeout(() => { - fitAddon.current.fit(); - resizeTerminal(); - terminalInstance.current.focus(); - }, 50); - const socket = io( window.location.hostname === "localhost" ? "http://localhost:8081" : "/", { - path: "/socket.io", + path: "/ssh.io/socket.io", transports: ["websocket", "polling"], } ); socketRef.current = socket; + socket.on("connect_error", (error) => { + terminalInstance.current.write(`\r\n*** Socket connection error: ${error.message} ***\r\n`); + }); + + socket.on("connect_timeout", () => { + terminalInstance.current.write(`\r\n*** Socket connection timeout ***\r\n`); + }); + + socket.on("error", (err) => { + const isAuthError = err.toLowerCase().includes("authentication") || err.toLowerCase().includes("auth"); + if (isAuthError && !hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) { + authModalShown = true; + setIsNoAuthHidden(false); + } + terminalInstance.current.write(`\r\n*** Error: ${err} ***\r\n`); + }); + socket.on("connect", () => { fitAddon.current.fit(); resizeTerminal(); const { cols, rows } = terminalInstance.current; - socket.emit("connectToHost", cols, rows, hostConfig); + + if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim()) { + setIsNoAuthHidden(false); + return; + } + + const sshConfig = { + ip: hostConfig.ip, + user: hostConfig.user, + port: Number(hostConfig.port) || 22, + password: hostConfig.password?.trim(), + rsaKey: hostConfig.rsaKey?.trim() + }; + + socket.emit("connectToHost", cols, rows, sshConfig); }); + setTimeout(() => { + fitAddon.current.fit(); + resizeTerminal(); + terminalInstance.current.focus(); + }, 50); + socket.on("data", (data) => { const decoder = new TextDecoder("utf-8"); terminalInstance.current.write(decoder.decode(new Uint8Array(data))); @@ -91,24 +122,41 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => { }); terminalInstance.current.attachCustomKeyEventHandler((event) => { - console.log("Event caled"); - if (isPasting) return; - - isPasting = true; - setTimeout(() => { - isPasting = false; - }, 200); - if ((event.ctrlKey || event.metaKey) && event.key === "v") { + if (isPasting) return false; + isPasting = true; + event.preventDefault(); navigator.clipboard.readText().then((text) => { - socketRef.current.emit("data", text); + text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const lines = text.split("\n"); + + if (socketRef.current) { + let index = 0; + + const sendLine = () => { + if (index < lines.length) { + socketRef.current.emit("data", lines[index] + "\r"); + index++; + setTimeout(sendLine, 10); + } else { + isPasting = false; + } + }; + + sendLine(); + } else { + isPasting = false; + } }).catch((err) => { console.error("Failed to read clipboard contents:", err); + isPasting = false; }); + return false; } + return true; }); @@ -121,13 +169,25 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => { } }); - socket.on("error", (err) => { - terminalInstance.current.write(`\r\n*** Error: ${err} ***\r\n`); + let authModalShown = false; + + socket.on("noAuthRequired", () => { + if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) { + authModalShown = true; + setIsNoAuthHidden(false); + } }); return () => { - terminalInstance.current.dispose(); - socket.disconnect(); + if (terminalInstance.current) { + terminalInstance.current.dispose(); + terminalInstance.current = null; + } + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + } + authModalShown = false; }; }, [hostConfig]); @@ -174,8 +234,10 @@ NewTerminal.propTypes = { hostConfig: PropTypes.shape({ ip: PropTypes.string.isRequired, user: PropTypes.string.isRequired, - password: PropTypes.string.isRequired, - port: PropTypes.string.isRequired, + password: PropTypes.string, + rsaKey: PropTypes.string, + port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, }).isRequired, isVisible: PropTypes.bool.isRequired, -}; + setIsNoAuthHidden: PropTypes.func.isRequired, +}; \ No newline at end of file diff --git a/src/apps/user/User.jsx b/src/apps/user/User.jsx new file mode 100644 index 00000000..b135a840 --- /dev/null +++ b/src/apps/user/User.jsx @@ -0,0 +1,309 @@ +import { useRef, forwardRef, useImperativeHandle, useEffect } from "react"; +import io from "socket.io-client"; +import PropTypes from "prop-types"; + +const SOCKET_URL = window.location.hostname === "localhost" + ? "http://localhost:8082/database.io" + : "/database.io"; + +const socket = io(SOCKET_URL, { + path: "/database.io/socket.io", + transports: ["websocket", "polling"], + autoConnect: false, +}); + +export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSuccess, onFailure }, ref) => { + const socketRef = useRef(socket); + const currentUser = useRef(null); + + useEffect(() => { + socketRef.current.connect(); + return () => socketRef.current.disconnect(); + }, []); + + useEffect(() => { + const verifySession = async () => { + const storedSession = localStorage.getItem("sessionToken"); + if (!storedSession || storedSession === "undefined") return; + + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("verifySession", { sessionToken: storedSession }, resolve); + }); + + if (response?.success) { + currentUser.current = { + id: response.user.id, + username: response.user.username, + sessionToken: storedSession, + }; + onLoginSuccess(response.user); + } else { + localStorage.removeItem("sessionToken"); + onFailure("Session expired"); + } + } catch (error) { + onFailure(error.message); + } + }; + + verifySession(); + }, []); + + const createUser = async (userConfig) => { + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("createUser", userConfig, resolve); + }); + + if (response?.user?.sessionToken) { + currentUser.current = { + id: response.user.id, + username: response.user.username, + sessionToken: response.user.sessionToken, + }; + localStorage.setItem("sessionToken", response.user.sessionToken); + onCreateSuccess(response.user); + } else { + throw new Error(response?.error || "User creation failed"); + } + } catch (error) { + onFailure(error.message); + } + }; + + const loginUser = async ({ username, password, sessionToken }) => { + try { + const response = await new Promise((resolve) => { + const credentials = sessionToken ? { sessionToken } : { username, password }; + socketRef.current.emit("loginUser", credentials, resolve); + }); + + if (response?.success) { + currentUser.current = { + id: response.user.id, + username: response.user.username, + sessionToken: response.user.sessionToken, + }; + localStorage.setItem("sessionToken", response.user.sessionToken); + onLoginSuccess(response.user); + } else { + throw new Error(response?.error || "Login failed"); + } + } catch (error) { + onFailure(error.message); + } + }; + + const loginAsGuest = async () => { + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("loginAsGuest", resolve); + }); + + if (response?.success) { + currentUser.current = { + id: response.user.id, + username: response.user.username, + sessionToken: response.user.sessionToken, + }; + localStorage.setItem("sessionToken", response.user.sessionToken); + onLoginSuccess(response.user); + } else { + throw new Error(response?.error || "Guest login failed"); + } + } catch (error) { + onFailure(error.message); + } + } + + const logoutUser = () => { + localStorage.removeItem("sessionToken"); + currentUser.current = null; + onLoginSuccess(null); + }; + + const deleteUser = async () => { + if (!currentUser.current) return onFailure("No user logged in"); + + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("deleteUser", { + userId: currentUser.current.id, + sessionToken: currentUser.current.sessionToken, + }, resolve); + }); + + if (response?.success) { + logoutUser(); + onDeleteSuccess(response); + } else { + throw new Error(response?.error || "User deletion failed"); + } + } catch (error) { + onFailure(error.message); + } + }; + + const saveHost = async (hostConfig) => { + if (!currentUser.current) return onFailure("Not authenticated"); + + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("saveHostConfig", { + userId: currentUser.current.id, + sessionToken: currentUser.current.sessionToken, + ...hostConfig + }, resolve); + }); + + if (!response?.success) { + throw new Error(response?.error || "Failed to save host"); + } + } catch (error) { + onFailure(error.message); + } + }; + + const getAllHosts = async () => { + if (!currentUser.current) return []; + + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("getHosts", { + userId: currentUser.current.id, + sessionToken: currentUser.current.sessionToken, + }, resolve); + }); + + if (response?.success) { + return response.hosts.map(host => ({ + ...host, + config: host.config ? { + name: host.config.name || '', + folder: host.config.folder || '', + ip: host.config.ip || '', + user: host.config.user || '', + port: host.config.port || '22', + password: host.config.password || '', + rsaKey: host.config.rsaKey || '', + } : {} + })).filter(host => host.config && host.config.ip && host.config.user); + } else { + throw new Error(response?.error || "Failed to fetch hosts"); + } + } catch (error) { + onFailure(error.message); + return []; + } + }; + + const deleteHost = async ({ hostId }) => { + if (!currentUser.current) return onFailure("Not authenticated"); + + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("deleteHost", { + userId: currentUser.current.id, + sessionToken: currentUser.current.sessionToken, + hostId: hostId, + }, resolve); + }); + + if (!response?.success) { + throw new Error(response?.error || "Failed to delete host"); + } + } catch (error) { + onFailure(error.message); + } + }; + + const editHost = async ({ oldHostConfig, newHostConfig }) => { + if (!currentUser.current) return onFailure("Not authenticated"); + + try { + console.log('Editing host with configs:', { oldHostConfig, newHostConfig }); + const response = await new Promise((resolve) => { + socketRef.current.emit("editHost", { + userId: currentUser.current.id, + sessionToken: currentUser.current.sessionToken, + oldHostConfig, + newHostConfig, + }, resolve); + }); + + if (!response?.success) { + throw new Error(response?.error || "Failed to edit host"); + } + } catch (error) { + onFailure(error.message); + } + }; + + const shareHost = async (hostId, targetUsername) => { + if (!currentUser.current) return onFailure("Not authenticated"); + + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("shareHost", { + userId: currentUser.current.id, + sessionToken: currentUser.current.sessionToken, + hostId, + targetUsername, + }, resolve); + }); + + if (!response?.success) { + throw new Error(response?.error || "Failed to share host"); + } + } catch (error) { + onFailure(error.message); + } + }; + + const removeShare = async (hostId) => { + if (!currentUser.current) return onFailure("Not authenticated"); + + try { + const response = await new Promise((resolve) => { + socketRef.current.emit("removeShare", { + userId: currentUser.current.id, + sessionToken: currentUser.current.sessionToken, + hostId, + }, resolve); + }); + + if (!response?.success) { + throw new Error(response?.error || "Failed to remove share"); + } + } catch (error) { + onFailure(error.message); + } + }; + + useImperativeHandle(ref, () => ({ + createUser, + loginUser, + loginAsGuest, + logoutUser, + deleteUser, + saveHost, + getAllHosts, + deleteHost, + shareHost, + editHost, + removeShare, + getUser: () => currentUser.current, + })); + + return null; +}); + +User.displayName = "User"; + +User.propTypes = { + onLoginSuccess: PropTypes.func.isRequired, + onCreateSuccess: PropTypes.func.isRequired, + onDeleteSuccess: PropTypes.func.isRequired, + onFailure: PropTypes.func.isRequired, +}; \ No newline at end of file diff --git a/src/backend/database.cjs b/src/backend/database.cjs new file mode 100644 index 00000000..1e453db3 --- /dev/null +++ b/src/backend/database.cjs @@ -0,0 +1,460 @@ +const http = require('http'); +const socketIo = require('socket.io'); +const mongoose = require('mongoose'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +require('dotenv').config(); + +const logger = { + info: (...args) => console.log(`🔧 [${new Date().toISOString()}] INFO:`, ...args), + error: (...args) => console.error(`❌ [${new Date().toISOString()}] ERROR:`, ...args), + warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args), + debug: (...args) => console.debug(`🔍 [${new Date().toISOString()}] DEBUG:`, ...args) +}; + +const server = http.createServer(); +const io = socketIo(server, { + path: '/database.io/socket.io', + cors: { origin: '*', methods: ['GET', 'POST'] } +}); + +const userSchema = new mongoose.Schema({ + username: { type: String, required: true, unique: true }, + password: { type: String, required: true }, + sessionToken: { type: String, required: true } +}); + +const hostSchema = new mongoose.Schema({ + name: { type: String, required: true }, + config: { type: String, required: true }, + users: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + folder: { type: String, default: null } +}); + +const User = mongoose.model('User', userSchema); +const Host = mongoose.model('Host', hostSchema); + +const getEncryptionKey = (userId, sessionToken) => { + const salt = process.env.SALT || 'default_salt'; + return crypto.scryptSync(`${userId}-${sessionToken}`, salt, 32); +}; + +const encryptData = (data, userId, sessionToken) => { + try { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', getEncryptionKey(userId, sessionToken), iv); + const encrypted = Buffer.concat([cipher.update(JSON.stringify(data)), cipher.final()]); + return `${iv.toString('hex')}:${encrypted.toString('hex')}:${cipher.getAuthTag().toString('hex')}`; + } catch (error) { + logger.error('Encryption failed:', error); + return null; + } +}; + +const decryptData = (encryptedData, userId, sessionToken) => { + try { + const [ivHex, contentHex, authTagHex] = encryptedData.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const content = Buffer.from(contentHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + + const decipher = crypto.createDecipheriv('aes-256-gcm', getEncryptionKey(userId, sessionToken), iv); + decipher.setAuthTag(authTag); + + return JSON.parse(Buffer.concat([decipher.update(content), decipher.final()]).toString()); + } catch (error) { + logger.error('Decryption failed:', error); + return null; + } +}; + +mongoose.connect(process.env.MONGO_URL || 'mongodb://localhost:27017/termix') + .then(() => logger.info('Connected to MongoDB')) + .catch(err => logger.error('MongoDB connection error:', err)); + +io.of('/database.io').on('connection', (socket) => { + socket.on('createUser', async ({ username, password }, callback) => { + try { + logger.debug(`Creating user: ${username}`); + + if (await User.exists({ username })) { + logger.warn(`Username already exists: ${username}`); + return callback({ error: 'Username already exists' }); + } + + const sessionToken = crypto.randomBytes(64).toString('hex'); + const user = await User.create({ + username, + password: await bcrypt.hash(password, 10), + sessionToken + }); + + logger.info(`User created: ${username}`); + callback({ success: true, user: { + id: user._id, + username: user.username, + sessionToken + }}); + } catch (error) { + logger.error('User creation error:', error); + callback({ error: 'User creation failed' }); + } + }); + + socket.on('loginUser', async ({ username, password, sessionToken }, callback) => { + try { + let user; + if (sessionToken) { + user = await User.findOne({ sessionToken }); + } else { + user = await User.findOne({ username }); + if (!user || !(await bcrypt.compare(password, user.password))) { + logger.warn(`Invalid credentials for: ${username}`); + return callback({ error: 'Invalid credentials' }); + } + } + + if (!user) { + logger.warn('Login failed - user not found'); + return callback({ error: 'Invalid credentials' }); + } + + logger.info(`User logged in: ${user.username}`); + callback({ success: true, user: { + id: user._id, + username: user.username, + sessionToken: user.sessionToken + }}); + } catch (error) { + logger.error('Login error:', error); + callback({ error: 'Login failed' }); + } + }); + + socket.on('loginAsGuest', async (callback) => { + try { + const username = `guest-${crypto.randomBytes(4).toString('hex')}`; + const sessionToken = crypto.randomBytes(64).toString('hex'); + + const user = await User.create({ + username, + password: await bcrypt.hash(username, 10), + sessionToken + }); + + logger.info(`Guest user created: ${username}`); + callback({ success: true, user: { + id: user._id, + username: user.username, + sessionToken + }}); + } catch (error) { + logger.error('Guest login error:', error); + callback({error: 'Guest login failed'}); + } + }); + + socket.on('saveHostConfig', async ({ userId, sessionToken, hostConfig }, callback) => { + try { + if (!userId || !sessionToken) { + logger.warn('Missing authentication parameters'); + return callback({ error: 'Authentication required' }); + } + + if (!hostConfig || typeof hostConfig !== 'object') { + logger.warn('Invalid host config format'); + return callback({ error: 'Invalid host configuration' }); + } + + if (!hostConfig.ip || !hostConfig.user) { + logger.warn('Missing required fields:', hostConfig); + return callback({ error: 'IP and User are required' }); + } + + const user = await User.findOne({ _id: userId, sessionToken }); + if (!user) { + logger.warn(`Invalid session for user: ${userId}`); + return callback({ error: 'Invalid session' }); + } + + const cleanConfig = { + name: hostConfig.name?.trim(), + folder: hostConfig.folder?.trim() || null, + ip: hostConfig.ip.trim(), + user: hostConfig.user.trim(), + port: hostConfig.port || 22, + password: hostConfig.password?.trim() || undefined, + rsaKey: hostConfig.rsaKey?.trim() || undefined + }; + + const finalName = cleanConfig.name || cleanConfig.ip; + + const existingHost = await Host.findOne({ + name: finalName, + createdBy: userId + }); + + if (existingHost) { + logger.warn(`Host with name ${finalName} already exists for user: ${userId}`); + return callback({ error: 'Host with this name already exists' }); + } + + const encryptedConfig = encryptData(cleanConfig, userId, sessionToken); + if (!encryptedConfig) { + logger.error('Encryption failed for host config'); + return callback({ error: 'Configuration encryption failed' }); + } + + await Host.create({ + name: finalName, + config: encryptedConfig, + users: [userId], + createdBy: userId, + folder: cleanConfig.folder + }); + + logger.info(`Host created successfully: ${finalName}`); + callback({ success: true }); + } catch (error) { + logger.error('Host save error:', error); + callback({ error: `Host save failed: ${error.message}` }); + } + }); + + socket.on('getHosts', async ({ userId, sessionToken }, callback) => { + try { + const user = await User.findOne({ _id: userId, sessionToken }); + if (!user) { + logger.warn(`Invalid session for user: ${userId}`); + return callback({ error: 'Invalid session' }); + } + + const hosts = await Host.find({ users: userId }).populate('createdBy'); + const decryptedHosts = await Promise.all(hosts.map(async host => { + try { + const ownerUser = host.createdBy; + if (!ownerUser) { + logger.warn(`Owner not found for host: ${host._id}`); + return null; + } + + const decryptedConfig = decryptData(host.config, ownerUser._id.toString(), ownerUser.sessionToken); + if (!decryptedConfig) { + logger.warn(`Failed to decrypt host config for host: ${host._id}`); + return null; + } + + return { + ...host.toObject(), + config: decryptedConfig + }; + } catch (error) { + logger.error(`Failed to process host ${host._id}:`, error); + return null; + } + })); + + callback({ success: true, hosts: decryptedHosts.filter(host => host && host.config) }); + } catch (error) { + logger.error('Get hosts error:', error); + callback({ error: 'Failed to fetch hosts' }); + } + }); + + socket.on('deleteHost', async ({ userId, sessionToken, hostId }, callback) => { + try { + logger.debug(`Deleting host: ${hostId} for user: ${userId}`); + + if (!userId || !sessionToken) { + logger.warn('Missing authentication parameters'); + return callback({ error: 'Authentication required' }); + } + + if (!hostId || typeof hostId !== 'string') { + logger.warn('Invalid host ID format'); + return callback({ error: 'Invalid host ID' }); + } + + const user = await User.findOne({ _id: userId, sessionToken }); + if (!user) { + logger.warn(`Invalid session for user: ${userId}`); + return callback({ error: 'Invalid session' }); + } + + const result = await Host.deleteOne({ _id: hostId, createdBy: userId }); + if (result.deletedCount === 0) { + logger.warn(`Host not found or not authorized: ${hostId}`); + return callback({ error: 'Host not found or not authorized' }); + } + + logger.info(`Host deleted: ${hostId}`); + callback({ success: true }); + } catch (error) { + logger.error('Host deletion error:', error); + callback({ error: `Host deletion failed: ${error.message}` }); + } + }); + + socket.on('shareHost', async ({ userId, sessionToken, hostId, targetUsername }, callback) => { + try { + logger.debug(`Sharing host ${hostId} with ${targetUsername}`); + + const user = await User.findOne({ _id: userId, sessionToken }); + if (!user) { + logger.warn(`Invalid session for user: ${userId}`); + return callback({ error: 'Invalid session' }); + } + + const targetUser = await User.findOne({ username: targetUsername }); + if (!targetUser) { + logger.warn(`Target user not found: ${targetUsername}`); + return callback({ error: 'User not found' }); + } + + const host = await Host.findOne({ _id: hostId, createdBy: userId }); + if (!host) { + logger.warn(`Host not found or unauthorized: ${hostId}`); + return callback({ error: 'Host not found' }); + } + + if (host.users.includes(targetUser._id)) { + logger.warn(`Host already shared with user: ${targetUsername}`); + return callback({ error: 'Already shared' }); + } + + host.users.push(targetUser._id); + await host.save(); + + logger.info(`Host shared successfully: ${hostId} -> ${targetUsername}`); + callback({ success: true }); + } catch (error) { + logger.error('Host sharing error:', error); + callback({ error: 'Failed to share host' }); + } + }); + + socket.on('removeShare', async ({ userId, sessionToken, hostId }, callback) => { + try { + logger.debug(`Removing share for host ${hostId} from user ${userId}`); + + const user = await User.findOne({ _id: userId, sessionToken }); + if (!user) { + logger.warn(`Invalid session for user: ${userId}`); + return callback({ error: 'Invalid session' }); + } + + const host = await Host.findById(hostId); + if (!host) { + logger.warn(`Host not found: ${hostId}`); + return callback({ error: 'Host not found' }); + } + + host.users = host.users.filter(id => id.toString() !== userId); + await host.save(); + + logger.info(`Share removed successfully: ${hostId} -> ${userId}`); + callback({ success: true }); + } catch (error) { + logger.error('Share removal error:', error); + callback({ error: 'Failed to remove share' }); + } + }); + + socket.on('deleteUser', async ({ userId, sessionToken }, callback) => { + try { + logger.debug(`Deleting user: ${userId}`); + + const user = await User.findOne({ _id: userId, sessionToken }); + if (!user) { + logger.warn(`Invalid session for user: ${userId}`); + return callback({ error: 'Invalid session' }); + } + + await Host.deleteMany({ createdBy: userId }); + await User.deleteOne({ _id: userId }); + + logger.info(`User deleted: ${userId}`); + callback({ success: true }); + } catch (error) { + logger.error('User deletion error:', error); + callback({ error: 'Failed to delete user' }); + } + }); + + socket.on("editHost", async ({ userId, sessionToken, oldHostConfig, newHostConfig }, callback) => { + try { + logger.debug(`Editing host for user: ${userId}`); + + if (!oldHostConfig || !newHostConfig) { + logger.warn('Missing host configurations'); + return callback({ error: 'Missing host configurations' }); + } + + const user = await User.findOne({ _id: userId, sessionToken }); + if (!user) { + logger.warn(`Invalid session for user: ${userId}`); + return callback({ error: 'Invalid session' }); + } + + const hosts = await Host.find({ createdBy: userId }); + const host = hosts.find(h => { + const decryptedConfig = decryptData(h.config, userId, sessionToken); + return decryptedConfig && decryptedConfig.ip === oldHostConfig.ip; + }); + + if (!host) { + logger.warn(`Host not found or unauthorized`); + return callback({ error: 'Host not found' }); + } + + const cleanConfig = { + name: newHostConfig.name?.trim(), + folder: newHostConfig.folder?.trim() || null, + ip: newHostConfig.ip.trim(), + user: newHostConfig.user.trim(), + port: newHostConfig.port || 22, + password: newHostConfig.password?.trim() || undefined, + rsaKey: newHostConfig.rsaKey?.trim() || undefined + }; + + const encryptedConfig = encryptData(cleanConfig, userId, sessionToken); + if (!encryptedConfig) { + logger.error('Encryption failed for host config'); + return callback({ error: 'Configuration encryption failed' }); + } + + host.config = encryptedConfig; + host.folder = cleanConfig.folder; + await host.save(); + + logger.info(`Host edited successfully`); + callback({ success: true }); + } catch (error) { + logger.error('Host edit error:', error); + callback({ error: 'Failed to edit host' }); + } + }); + + socket.on('verifySession', async ({ sessionToken }, callback) => { + try { + const user = await User.findOne({ sessionToken }); + if (!user) { + logger.warn(`Invalid session token: ${sessionToken}`); + return callback({ error: 'Invalid session' }); + } + + callback({ success: true, user: { + id: user._id, + username: user.username + }}); + } catch (error) { + logger.error('Session verification error:', error); + callback({ error: 'Session verification failed' }); + } + }); +}); + +server.listen(8082, () => { + logger.info('Server running on port 8082'); +}); \ No newline at end of file diff --git a/src/backend/server.cjs b/src/backend/ssh.cjs similarity index 63% rename from src/backend/server.cjs rename to src/backend/ssh.cjs index 49fef97c..0fd3ba16 100644 --- a/src/backend/server.cjs +++ b/src/backend/ssh.cjs @@ -4,6 +4,7 @@ const SSHClient = require("ssh2").Client; const server = http.createServer(); const io = socketIo(server, { + path: "/ssh.io/socket.io", cors: { origin: "*", methods: ["GET", "POST"], @@ -12,77 +13,84 @@ const io = socketIo(server, { allowEIO3: true }); +const logger = { + info: (...args) => console.log(`🔧 [${new Date().toISOString()}] INFO:`, ...args), + error: (...args) => console.error(`❌ [${new Date().toISOString()}] ERROR:`, ...args), + warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args), + debug: (...args) => console.debug(`🔍 [${new Date().toISOString()}] DEBUG:`, ...args) +}; + io.on("connection", (socket) => { - console.log("New socket connection established"); + logger.info("New socket connection established"); let stream = null; socket.on("connectToHost", (cols, rows, hostConfig) => { - if (!hostConfig || !hostConfig.ip || !hostConfig.user || (!hostConfig.password && !hostConfig.rsaKey) || !hostConfig.port) { - console.error("Invalid hostConfig received:", hostConfig); + if (!hostConfig || !hostConfig.ip || !hostConfig.user || !hostConfig.port) { + logger.error("Invalid hostConfig received - missing required fields:", hostConfig); + socket.emit("error", "Missing required connection details (IP, user, or port)"); + return; + } + + if (!hostConfig.password && !hostConfig.rsaKey) { + logger.error("No authentication provided"); + socket.emit("error", "Authentication required"); return; } - // Redact only sensitive info for logging const safeHostConfig = { ip: hostConfig.ip, port: hostConfig.port, user: hostConfig.user, - password: hostConfig.password ? '***REDACTED***' : undefined, - rsaKey: hostConfig.rsaKey ? '***REDACTED***' : undefined, + authType: hostConfig.password ? 'password' : 'public key', }; - console.log("Received hostConfig:", safeHostConfig); + logger.info("Connecting with config:", safeHostConfig); const { ip, port, user, password, rsaKey } = hostConfig; const conn = new SSHClient(); conn .on("ready", function () { - console.log("SSH connection established"); + logger.info("SSH connection established"); conn.shell({ term: "xterm-256color" }, function (err, newStream) { if (err) { - console.error("Error:", err.message); + logger.error("Shell error:", err.message); socket.emit("error", err.message); return; } stream = newStream; - // Set initial terminal size stream.setWindow(rows, cols, rows * 100, cols * 100); - // Pipe SSH output to client stream.on("data", function (data) { socket.emit("data", data); }); stream.on("close", function () { - console.log("SSH stream closed"); + logger.info("SSH stream closed"); conn.end(); }); - // Send keystrokes from terminal to SSH socket.on("data", function (data) { stream.write(data); }); - // Resize SSH terminal when client resizes socket.on("resize", ({ cols, rows }) => { if (stream && stream.setWindow) { stream.setWindow(rows, cols, rows * 100, cols * 100); } }); - // Auto-send initial terminal size to backend socket.emit("resize", { cols, rows }); }); }) .on("close", function () { - console.log("SSH connection closed"); + logger.info("SSH connection closed"); socket.emit("error", "SSH connection closed"); }) .on("error", function (err) { - console.error("Error:", err.message); + logger.error("Error:", err.message); socket.emit("error", err.message); }) .connect({ @@ -95,10 +103,10 @@ io.on("connection", (socket) => { }); socket.on("disconnect", () => { - console.log("Client disconnected"); + logger.info("Client disconnected"); }); }); server.listen(8081, '0.0.0.0', () => { - console.log("Server is running on port 8081"); + logger.info("Server is running on port 8081"); }); \ No newline at end of file diff --git a/src/images/host_viewer_icon.png b/src/images/host_viewer_icon.png new file mode 100644 index 00000000..03bc917c Binary files /dev/null and b/src/images/host_viewer_icon.png differ diff --git a/src/images/profile_icon.png b/src/images/profile_icon.png new file mode 100644 index 00000000..eb9822db Binary files /dev/null and b/src/images/profile_icon.png differ diff --git a/src/main.jsx b/src/main.jsx index 9347e151..3d2217b9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,7 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.jsx' createRoot(document.getElementById('root')).render( - - , ) \ No newline at end of file diff --git a/src/modals/AddHostModal.jsx b/src/modals/AddHostModal.jsx new file mode 100644 index 00000000..01880945 --- /dev/null +++ b/src/modals/AddHostModal.jsx @@ -0,0 +1,394 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { + Modal, + Button, + FormControl, + FormLabel, + Input, + Stack, + DialogTitle, + DialogContent, + ModalDialog, + Select, + Option, + Checkbox, + IconButton, + Tabs, + TabList, + Tab, + TabPanel +} from '@mui/joy'; +import theme from '/src/theme'; +import { useState } from 'react'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; + +const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidden }) => { + const [showPassword, setShowPassword] = useState(false); + const [activeTab, setActiveTab] = useState(0); + + const handleFileChange = (e) => { + const file = e.target.files[0]; + if (file) { + if (file.name.endsWith('.key') || file.name.endsWith('.pem') || file.name.endsWith('.pub')) { + const reader = new FileReader(); + reader.onload = (event) => { + setForm({ ...form, rsaKey: event.target.result }); + }; + reader.readAsText(file); + } else { + alert("Please upload a valid public key file."); + } + } + }; + + const handleAuthChange = (newMethod) => { + setForm((prev) => ({ + ...prev, + authMethod: newMethod, + password: "", + rsaKey: "" + })); + }; + + const isFormValid = () => { + if (!form.ip || !form.user || !form.port) return false; + const portNum = Number(form.port); + if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false; + + if (form.rememberHost) { + if (form.authMethod === 'Select Auth') return false; + if (form.authMethod === 'rsaKey' && !form.rsaKey) return false; + if (form.authMethod === 'password' && !form.password) return false; + } + + return true; + }; + + const handleSubmit = (event) => { + event.preventDefault(); + if (isFormValid()) { + if (!form.rememberHost) { + handleAddHost(); + } else { + handleAddHost(); + } + + setForm({ + name: '', + folder: '', + ip: '', + user: '', + password: '', + rsaKey: '', + port: 22, + authMethod: 'Select Auth', + rememberHost: false, + storePassword: true, + }); + setIsAddHostHidden(true); + } + }; + + return ( + + setIsAddHostHidden(true)} + sx={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }} + > + + Add Host + +
+ setActiveTab(val)} + sx={{ + backgroundColor: theme.palette.general.disabled, + borderRadius: '8px', + padding: '8px', + marginBottom: '16px', + width: '100%', + }} + > + + Basic Info + Connection + Authentication + + + + + + Host Name + setForm({ ...form, name: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Folder + setForm({ ...form, folder: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + + + + + + Host IP + setForm({ ...form, ip: e.target.value })} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Host User + setForm({ ...form, user: e.target.value })} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + 65535}> + Host Port + setForm({ ...form, port: e.target.value })} + min={1} + max={65535} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + + + + + + Remember Host + setForm({ + ...form, + rememberHost: e.target.checked, + + ...((!e.target.checked) && { + authMethod: 'Select Auth', + password: '', + rsaKey: '', + storePassword: true + }) + })} + sx={{ + color: theme.palette.text.primary, + '&.Mui-checked': { + color: theme.palette.text.primary, + }, + }} + /> + + {form.rememberHost && ( + <> + + Store Password + setForm({ ...form, storePassword: e.target.checked })} + sx={{ + color: theme.palette.text.primary, + '&.Mui-checked': { + color: theme.palette.text.primary, + }, + }} + /> + + + Authentication Method + + + + {form.authMethod === 'password' && ( + + Password +
+ setForm({ ...form, password: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + flex: 1 + }} + /> + setShowPassword(!showPassword)} + sx={{ + color: theme.palette.text.primary, + marginLeft: 1 + }} + > + {showPassword ? : } + +
+
+ )} + + {form.authMethod === 'rsaKey' && ( + + Public Key + + + )} + + )} +
+
+
+ + +
+
+
+
+
+ ); +}; + +AddHostModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + form: PropTypes.shape({ + name: PropTypes.string, + folder: PropTypes.string, + ip: PropTypes.string.isRequired, + user: PropTypes.string.isRequired, + password: PropTypes.string, + rsaKey: PropTypes.string, + port: PropTypes.number.isRequired, + authMethod: PropTypes.string.isRequired, + rememberHost: PropTypes.bool, + storePassword: PropTypes.bool, + }).isRequired, + setForm: PropTypes.func.isRequired, + handleAddHost: PropTypes.func.isRequired, + setIsAddHostHidden: PropTypes.func.isRequired, +}; + +export default AddHostModal; \ No newline at end of file diff --git a/src/modals/CreateUserModal.jsx b/src/modals/CreateUserModal.jsx new file mode 100644 index 00000000..c48a18cd --- /dev/null +++ b/src/modals/CreateUserModal.jsx @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog, IconButton } from '@mui/joy'; +import theme from '/src/theme'; +import { useEffect, useState } from 'react'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; + +const CreateUserModal = ({ isHidden, form, setForm, handleCreateUser, setIsCreateUserHidden, setIsLoginUserHidden }) => { + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const isFormValid = () => { + if (!form.username || !form.password || form.password !== confirmPassword) return false; + return true; + }; + + const handleCreate = () => { + handleCreateUser({ + ...form + }); + }; + + useEffect(() => { + if (isHidden) { + setForm({ username: '', password: '' }); + setConfirmPassword(''); + } + }, [isHidden]); + + return ( + + {}}> + + Create + +
{ + event.preventDefault(); + if (isFormValid()) handleCreate(); + }} + > + + + Username + setForm({ ...form, username: event.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Password +
+ setForm({ ...form, password: event.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + flex: 1, + }} + /> + setShowPassword(!showPassword)} + sx={{ + color: theme.palette.text.primary, + marginLeft: 1, + }} + > + {showPassword ? : } + +
+
+ + Confirm Password +
+ setConfirmPassword(event.target.value)} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + flex: 1, + }} + /> + setShowConfirmPassword(!showConfirmPassword)} + sx={{ + color: theme.palette.text.primary, + marginLeft: 1, + }} + > + {showConfirmPassword ? : } + +
+
+ + +
+
+
+
+
+
+ ); +}; + +CreateUserModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + form: PropTypes.object.isRequired, + setForm: PropTypes.func.isRequired, + handleCreateUser: PropTypes.func.isRequired, + setIsCreateUserHidden: PropTypes.func.isRequired, + setIsLoginUserHidden: PropTypes.func.isRequired, +}; + +export default CreateUserModal; \ No newline at end of file diff --git a/src/modals/EditHostModal.jsx b/src/modals/EditHostModal.jsx new file mode 100644 index 00000000..048fb12d --- /dev/null +++ b/src/modals/EditHostModal.jsx @@ -0,0 +1,396 @@ +import PropTypes from 'prop-types'; +import { useEffect, useState } from 'react'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { + Modal, + Button, + FormControl, + FormLabel, + Input, + Stack, + DialogTitle, + DialogContent, + ModalDialog, + Select, + Option, + IconButton, + Checkbox, + Tabs, + TabList, + Tab, + TabPanel +} from '@mui/joy'; +import theme from '/src/theme'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; + +const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostHidden, hostConfig }) => { + const [showPassword, setShowPassword] = useState(false); + const [activeTab, setActiveTab] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!isHidden && hostConfig) { + setForm({ + name: hostConfig.name || '', + folder: hostConfig.folder || '', + ip: hostConfig.ip || '', + user: hostConfig.user || '', + password: hostConfig.password || '', + rsaKey: hostConfig.rsaKey || '', + port: hostConfig.port || 22, + authMethod: hostConfig.password ? 'password' : hostConfig.rsaKey ? 'rsaKey' : 'Select Auth', + rememberHost: true, + storePassword: !!(hostConfig.password || hostConfig.rsaKey), + }); + } + }, [isHidden, hostConfig]); + + const handleFileChange = (e) => { + const file = e.target.files[0]; + if (file.name.endsWith('.rsa') || file.name.endsWith('.key') || file.name.endsWith('.pem') || file.name.endsWith('.der') || file.name.endsWith('.p8') || file.name.endsWith('.ssh') || file.name.endsWith('.pub')) { + const reader = new FileReader(); + reader.onload = (evt) => { + setForm((prev) => ({ ...prev, rsaKey: evt.target.result })); + }; + reader.readAsText(file); + } else { + alert('Please upload a valid RSA private key file.'); + } + }; + + const handleAuthChange = (newMethod) => { + setForm((prev) => ({ + ...prev, + authMethod: newMethod + })); + }; + + const handleStorePasswordChange = (checked) => { + setForm((prev) => ({ + ...prev, + storePassword: Boolean(checked), + password: checked ? prev.password : "", + rsaKey: checked ? prev.rsaKey : "", + authMethod: checked ? prev.authMethod : "Select Auth" + })); + }; + + const isFormValid = () => { + const { ip, user, port, authMethod, password, rsaKey, storePassword } = form; + if (!ip?.trim() || !user?.trim() || !port) return false; + const portNum = Number(port); + if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false; + + if (Boolean(storePassword) && authMethod === 'password' && !password?.trim()) return false; + if (Boolean(storePassword) && authMethod === 'rsaKey' && !rsaKey && !hostConfig?.rsaKey) return false; + if (Boolean(storePassword) && authMethod === 'Select Auth') return false; + + return true; + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + if (isLoading) return; + + setIsLoading(true); + try { + await handleEditHost(hostConfig, { + name: form.name || form.ip, + folder: form.folder, + ip: form.ip, + user: form.user, + password: form.authMethod === 'password' ? form.password : undefined, + rsaKey: form.authMethod === 'rsaKey' ? form.rsaKey : undefined, + port: String(form.port), + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + !isLoading && setIsEditHostHidden(true)} + sx={{ + position: 'fixed', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backdropFilter: 'blur(5px)', + backgroundColor: 'rgba(0, 0, 0, 0.2)', + }} + > + + Edit Host + +
+ setActiveTab(val)} + sx={{ + backgroundColor: theme.palette.general.disabled, + borderRadius: '8px', + padding: '8px', + marginBottom: '16px', + width: '100%', + }} + > + + Basic Info + Connection + Authentication + + + + + + Host Name + setForm((prev) => ({ ...prev, name: e.target.value }))} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary + }} + /> + + + + Folder + setForm((prev) => ({ ...prev, folder: e.target.value }))} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary + }} + /> + + + + + + + + Host IP + setForm((prev) => ({ ...prev, ip: e.target.value }))} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary + }} + /> + + + 65535}> + Host Port + setForm((prev) => ({ ...prev, port: e.target.value }))} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary + }} + /> + + + + Host User + setForm((prev) => ({ ...prev, user: e.target.value }))} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary + }} + /> + + + + + + + + Store Password + handleStorePasswordChange(e.target.checked)} + sx={{ + color: theme.palette.text.primary, + '&.Mui-checked': { + color: theme.palette.text.primary + } + }} + /> + + + {form.storePassword && ( + + Authentication Method + + + )} + + {form.authMethod === 'password' && form.storePassword && ( + + Password +
+ setForm((prev) => ({ ...prev, password: e.target.value }))} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + flex: 1 + }} + /> + setShowPassword(!showPassword)} + sx={{ + color: theme.palette.text.primary, + marginLeft: 1 + }} + > + {showPassword ? : } + +
+
+ )} + + {form.authMethod === 'rsaKey' && form.storePassword && ( + + Public Key + + {hostConfig?.rsaKey && !form.rsaKey && ( + + Existing key detected. Upload to replace. + + )} + + )} +
+
+
+ + +
+
+
+
+
+ ); +}; + +EditHostModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + form: PropTypes.object.isRequired, + setForm: PropTypes.func.isRequired, + handleEditHost: PropTypes.func.isRequired, + setIsEditHostHidden: PropTypes.func.isRequired, + hostConfig: PropTypes.object +}; + +export default EditHostModal; \ No newline at end of file diff --git a/src/modals/ErrorModal.jsx b/src/modals/ErrorModal.jsx new file mode 100644 index 00000000..46590b07 --- /dev/null +++ b/src/modals/ErrorModal.jsx @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { Modal, Button, DialogTitle, DialogContent, ModalDialog } from '@mui/joy'; +import theme from '/src/theme'; + +const ErrorModal = ({ isHidden, errorMessage, setIsErrorHidden }) => { + return ( + + setIsErrorHidden(true)}> + + Error + + {errorMessage} + + + + + + ); +}; + +ErrorModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + errorMessage: PropTypes.string.isRequired, + setIsErrorHidden: PropTypes.func.isRequired, +}; + +export default ErrorModal; \ No newline at end of file diff --git a/src/modals/LoginUserModal.jsx b/src/modals/LoginUserModal.jsx new file mode 100644 index 00000000..efbe740f --- /dev/null +++ b/src/modals/LoginUserModal.jsx @@ -0,0 +1,150 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog, IconButton } from '@mui/joy'; +import theme from '/src/theme'; +import { useEffect, useState } from 'react'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; + +const LoginUserModal = ({ isHidden, form, setForm, handleLoginUser, handleGuestLogin, setIsLoginUserHidden, setIsCreateUserHidden }) => { + const [showPassword, setShowPassword] = useState(false); + + const isFormValid = () => { + if (!form.username || !form.password) return false; + return true; + }; + + const handleLogin = () => { + handleLoginUser({ + ...form, + }); + }; + + useEffect(() => { + if (isHidden) { + setForm({ username: '', password: '' }); + } + }, [isHidden]); + + return ( + + {}}> + + Login + +
{ + event.preventDefault(); + if (isFormValid()) handleLogin(); + }} + > + + + Username + setForm({ ...form, username: event.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Password +
+ setForm({ ...form, password: event.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + flex: 1, + }} + /> + setShowPassword(!showPassword)} + sx={{ + color: theme.palette.text.primary, + marginLeft: 1, + }} + > + {showPassword ? : } + +
+
+ + + +
+
+
+
+
+
+ ); +}; + +LoginUserModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + form: PropTypes.object.isRequired, + setForm: PropTypes.func.isRequired, + handleLoginUser: PropTypes.func.isRequired, + handleGuestLogin: PropTypes.func.isRequired, + setIsLoginUserHidden: PropTypes.func.isRequired, + setIsCreateUserHidden: PropTypes.func.isRequired, +}; + +export default LoginUserModal; \ No newline at end of file diff --git a/src/modals/NoAuthenticationModal.jsx b/src/modals/NoAuthenticationModal.jsx new file mode 100644 index 00000000..6829aaf0 --- /dev/null +++ b/src/modals/NoAuthenticationModal.jsx @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { + Modal, + Button, + FormControl, + FormLabel, + Input, + Stack, + DialogTitle, + DialogContent, + ModalDialog, + IconButton, + Select, + Option, +} from '@mui/joy'; +import theme from '/src/theme'; +import { useState, useEffect } from 'react'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; + +const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, handleAuthSubmit }) => { + const [showPassword, setShowPassword] = useState(false); + + useEffect(() => { + if (!form.authMethod) { + setForm(prev => ({ + ...prev, + authMethod: 'Select Auth' + })); + } + }, []); + + const isFormValid = () => { + if (!form.authMethod || form.authMethod === 'Select Auth') return false; + if (form.authMethod === 'rsaKey' && !form.rsaKey) return false; + if (form.authMethod === 'password' && !form.password) return false; + return true; + }; + + const handleSubmit = (event) => { + event.preventDefault(); + if (isFormValid()) { + handleAuthSubmit(form); + setForm({ authMethod: 'Select Auth', password: '', rsaKey: '' }); + } + }; + + return ( + + { + if (reason !== 'backdropClick') { + setIsNoAuthHidden(true); + } + }} + sx={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }} + > + + Authentication Required + +
+ + + Authentication Method + + + + {form.authMethod === 'password' && ( + + Password +
+ setForm(prev => ({ ...prev, password: e.target.value }))} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + flex: 1 + }} + /> + setShowPassword(!showPassword)} + sx={{ + color: theme.palette.text.primary, + marginLeft: 1 + }} + > + {showPassword ? : } + +
+
+ )} + + {form.authMethod === 'rsaKey' && ( + + Public Key + + + )} + + +
+
+
+
+
+
+ ); +}; + +NoAuthenticationModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + form: PropTypes.object.isRequired, + setForm: PropTypes.func.isRequired, + setIsNoAuthHidden: PropTypes.func.isRequired, + handleAuthSubmit: PropTypes.func.isRequired, +}; + +export default NoAuthenticationModal; \ No newline at end of file diff --git a/src/modals/ProfileModal.jsx b/src/modals/ProfileModal.jsx new file mode 100644 index 00000000..4b69d391 --- /dev/null +++ b/src/modals/ProfileModal.jsx @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import { Modal, Button } from "@mui/joy"; +import LogoutIcon from "@mui/icons-material/Logout"; +import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; +import theme from "../theme"; + +export default function ProfileModal({ + isHidden, + handleDeleteUser, + handleLogoutUser, + setIsProfileHidden, +}) { + return ( + setIsProfileHidden(true)} + sx={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + > +
+
+ + + +
+
+
+ ); +} + +ProfileModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + getUser: PropTypes.func.isRequired, + handleDeleteUser: PropTypes.func.isRequired, + handleLogoutUser: PropTypes.func.isRequired, + setIsProfileHidden: PropTypes.func.isRequired, +}; \ No newline at end of file diff --git a/src/modals/ShareHostModal.jsx b/src/modals/ShareHostModal.jsx new file mode 100644 index 00000000..24e4be3d --- /dev/null +++ b/src/modals/ShareHostModal.jsx @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import { useState } from 'react'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { + Modal, + Button, + FormControl, + FormLabel, + Input, + DialogTitle, + DialogContent, + ModalDialog, +} from '@mui/joy'; +import theme from '/src/theme'; + +const ShareHostModal = ({ isHidden, setIsHidden, handleShare, hostConfig }) => { + const [username, setUsername] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + event.stopPropagation(); + if (isLoading || !username.trim()) return; + + setIsLoading(true); + try { + await handleShare(hostConfig._id, username.trim()); + setUsername(''); + setIsHidden(true); + } finally { + setIsLoading(false); + } + }; + + const handleModalClick = (event) => { + event.stopPropagation(); + }; + + return ( + + !isLoading && setIsHidden(true)} + sx={{ + position: 'fixed', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backdropFilter: 'blur(5px)', + backgroundColor: 'rgba(0, 0, 0, 0.2)', + }} + > + + Share Host + +
e.stopPropagation()}> + + Username to share with + setUsername(e.target.value)} + placeholder="Enter username" + onClick={(e) => e.stopPropagation()} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + mb: 2 + }} + /> + + + +
+
+
+
+
+ ); +}; + +ShareHostModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + setIsHidden: PropTypes.func.isRequired, + handleShare: PropTypes.func.isRequired, + hostConfig: PropTypes.object +}; + +export default ShareHostModal; \ No newline at end of file diff --git a/src/Utils.jsx b/src/other/Utils.jsx similarity index 100% rename from src/Utils.jsx rename to src/other/Utils.jsx diff --git a/src/TabList.jsx b/src/ui/TabList.jsx similarity index 100% rename from src/TabList.jsx rename to src/ui/TabList.jsx diff --git a/vite.config.js b/vite.config.js index 5399993d..4f806a60 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,4 +5,10 @@ import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + + server: { + watch: { + ignored: ["**/docker/**"], + }, + }, }) \ No newline at end of file