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 @@
[](#)
[](#)
[](#)
-[](#)
+[](#)
[](#)
+[](#)
+[](#)
+
@@ -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

+
# 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
-
-
-
-
-
-
- );
-};
-
-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
+
+
+
+
+
+
+ );
+};
+
+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
+
+
+
+
+
+
+ );
+};
+
+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
+
+
+
+
+
+
+ );
+};
+
+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
+
+
+
+
+
+
+ );
+};
+
+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
+
+
+
+
+
+
+ );
+};
+
+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",
+ }}
+ >
+
+
+ }
+ sx={{
+ backgroundColor: theme.palette.general.tertiary,
+ color: "white",
+ "&:hover": {
+ backgroundColor: theme.palette.general.secondary,
+ },
+ height: "40px",
+ border: `1px solid ${theme.palette.general.secondary}`,
+ }}
+ >
+ Logout
+
+
+
+
+
+
+ );
+}
+
+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
+
+
+
+
+
+
+ );
+};
+
+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