From 6940f4e9bb3ac2746bd77f574c3c4c27dc7e8042 Mon Sep 17 00:00:00 2001 From: Karmaa <88517757+LukeGus@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:17:56 -0500 Subject: [PATCH] Dev 0.2.1 (#30) Change Log: - Renamed all release versions for consistency - Show version number in profile menu - Support for low end devices (switched MongoDB to version 4) - Better SSH key support (RSA, PEM, Key, DSA, ECDSA, ED25519) - Improve UI for logging in, creating hosts, and viewing hosts Bug Fixes: - SSH would disconnect if left opened for too long without activity - Pasting permission and formatting issues - No longer allow hosts to have the same name --- .github/workflows/docker-image.yml | 70 +++- docker/Dockerfile | 116 ++++-- docker/entrypoint.sh | 122 +++++- package-lock.json | 241 ++++++----- package.json | 5 +- src/App.jsx | 253 ++++++----- src/apps/Launchpad.jsx | 9 +- src/apps/ssh/HostViewer.jsx | 204 +++++---- src/apps/ssh/Terminal.jsx | 168 +++++--- src/apps/user/User.jsx | 36 +- src/backend/database.cjs | 66 ++- src/backend/ssh.cjs | 27 +- src/modals/AddHostModal.jsx | 602 ++++++++++++++------------- src/modals/AuthModal.jsx | 306 ++++++++++++++ src/modals/CreateUserModal.jsx | 166 -------- src/modals/EditHostModal.jsx | 599 +++++++++++++++----------- src/modals/LoginUserModal.jsx | 150 ------- src/modals/NoAuthenticationModal.jsx | 164 +++++--- src/modals/ProfileModal.jsx | 4 + src/other/eventBus.jsx | 5 + 20 files changed, 1962 insertions(+), 1351 deletions(-) create mode 100644 src/modals/AuthModal.jsx delete mode 100644 src/modals/CreateUserModal.jsx delete mode 100644 src/modals/LoginUserModal.jsx create mode 100644 src/other/eventBus.jsx diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 9ed4e074..a4a65db4 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -4,6 +4,9 @@ on: push: branches: - development + paths-ignore: + - '**.md' + - '.gitignore' workflow_dispatch: inputs: tag_name: @@ -16,27 +19,44 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 - - - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/checkout@v4 with: - node-version: '18' + fetch-depth: 1 - - name: Install Dependencies and Build Frontend - run: | - cd src - npm ci - npm run build + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 - - name: Setup QEMU - uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/amd64,linux/arm64 + driver-opts: | + image=moby/buildkit:master + network=host - - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v2 + - name: Cache npm dependencies + uses: actions/cache@v3 + with: + path: | + ~/.npm + node_modules + */*/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- - name: Login to Docker Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -53,21 +73,36 @@ jobs: echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV - name: Build and Push Multi-Arch Docker Image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . file: ./docker/Dockerfile push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ env.IMAGE_TAG }} - labels: org.opencontainers.image.source=https://github.com/${{ github.repository }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + build-args: | + BUILDKIT_INLINE_CACHE=1 + BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 + outputs: type=registry,compression=zstd,compression-level=19 + + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache - name: Notify via ntfy + if: success() run: | curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \ https://ntfy.karmaa.site/termix-build - name: Delete all untagged image versions + if: success() uses: quartx-analytics/ghcr-cleaner@v1 with: owner-type: user @@ -76,6 +111,7 @@ jobs: delete-untagged: true - name: Cleanup Docker Images Locally + if: always() run: | docker image prune -af docker system prune -af --volumes \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 8db6b186..8b93cf97 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,61 +1,107 @@ # Stage 1: Build frontend -FROM --platform=$BUILDPLATFORM node:18 AS frontend-builder +FROM node:18-alpine AS frontend-builder WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci --force && \ + npm cache clean --force COPY . . RUN npm run build # Stage 2: Build backend -FROM --platform=$BUILDPLATFORM node:18 AS backend-builder +FROM node:18-alpine AS backend-builder WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci --only=production --force && \ + npm cache clean --force COPY src/backend/ ./src/backend/ -# Stage 3: Final production image -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/* +# Stage 3: Build bcrypt for Ubuntu +FROM ubuntu:focal AS bcrypt-builder +ENV DEBIAN_FRONTEND=noninteractive \ + NODE_VERSION=18.x +WORKDIR /app +COPY package*.json ./ +RUN rm -f /var/lib/apt/lists/lock /var/cache/apt/archives/lock /var/lib/dpkg/lock* && \ + apt-get clean && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + wget \ + ca-certificates \ + gnupg && \ + wget -qO- https://deb.nodesource.com/setup_${NODE_VERSION} | bash - && \ + apt-get update && \ + apt-get install -y nodejs && \ + npm ci --only=production bcrypt --force && \ + npm cache clean --force && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* -# Configure nginx +# Final stage +FROM ubuntu:focal +ENV DEBIAN_FRONTEND=noninteractive \ + NODE_VERSION=18.x \ + MONGO_VERSION=4.4.24 \ + MONGO_URL=mongodb://localhost:27017/termix \ + MONGODB_DATA_DIR=/data/db \ + MONGODB_LOG_DIR=/var/log/mongodb \ + PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +# Create users first +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb \ + && groupadd -r node && useradd -r -g node -m node + +# Install all dependencies in one layer +RUN rm -f /var/lib/apt/lists/lock /var/cache/apt/archives/lock /var/lib/dpkg/lock* && \ + apt-get clean && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + gnupg \ + gosu \ + nginx-light \ + wget && \ + # Add MongoDB 4.4 repository + wget -qO - https://www.mongodb.org/static/pgp/server-4.4.asc | apt-key add - && \ + echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/4.4 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-4.4.list && \ + # Add MongoDB 5.0 repository + wget -qO - https://www.mongodb.org/static/pgp/server-5.0.asc | apt-key add - && \ + echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/5.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-5.0.list && \ + # Add NodeJS repository + wget -qO- https://deb.nodesource.com/setup_${NODE_VERSION} | bash - && \ + apt-get update && \ + # Install MongoDB 4.4 and 5.0 packages + apt-get install -y --no-install-recommends \ + nodejs \ + mongodb-org-server=${MONGO_VERSION} \ + mongodb-org-shell=${MONGO_VERSION} \ + mongodb-org-server=5.0.21 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* && \ + mkdir -p /data/db /var/log/mongodb /var/run/mongodb && \ + chown -R mongodb:mongodb /data/db /var/log/mongodb /var/run/mongodb && \ + chmod 755 /data/db /var/log/mongodb /var/run/mongodb + +# Setup nginx and frontend COPY docker/nginx.conf /etc/nginx/nginx.conf COPY --from=frontend-builder /app/dist /usr/share/nginx/html +RUN chown -R www-data:www-data /usr/share/nginx/html # Setup backend WORKDIR /app COPY package*.json ./ -RUN npm install --omit=dev +RUN npm ci --only=production --ignore-scripts --force && \ + npm cache clean --force && \ + rm -rf /tmp/* +COPY --from=bcrypt-builder /app/node_modules/bcrypt /app/node_modules/bcrypt COPY --from=backend-builder /app/src/backend ./src/backend +RUN chown -R node:node /app -# Create directories for MongoDB and nginx -RUN mkdir -p /data/db && \ - mkdir -p /var/log/nginx && \ - mkdir -p /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 8082 27017 -# Use a entrypoint script to run all services COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh CMD ["/entrypoint.sh"] \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index f372ddc8..0696ffc8 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,32 +1,124 @@ #!/bin/bash set -e -# Start MongoDB -echo "Starting MongoDB..." -mongod --fork --dbpath $MONGODB_DATA_DIR --logpath $MONGODB_LOG_DIR/mongodb.log +# Create required directories and set permissions +mkdir -p /data/db /var/log/mongodb /var/run/mongodb +chown -R mongodb:mongodb /data/db /var/log/mongodb /var/run/mongodb +chmod 755 /data/db /var/log/mongodb /var/run/mongodb + +# Function to check MongoDB version +check_mongo_version() { + echo "Checking MongoDB version..." + if [ -f "/data/db/diagnostic.data/metrics.2" ] || [ -f "/data/db/WiredTiger.wt" ]; then + echo "Existing MongoDB data detected, attempting migration..." + + # Clear any existing mongod lock file + rm -f /tmp/mongodb-27017.sock + rm -f /data/db/mongod.lock + + # First, start MongoDB 5.0 to set compatibility version + echo "Starting MongoDB 5.0 to set compatibility version..." + gosu mongodb /usr/bin/mongod --dbpath $MONGODB_DATA_DIR --port 27017 --bind_ip 127.0.0.1 --config /etc/mongod.conf & + MONGO_PID=$! + + # Wait for MongoDB 5.0 to start + echo "Waiting for MongoDB 5.0 to start..." + MAX_TRIES=30 + COUNT=0 + while ! gosu mongodb mongo --quiet --eval "db.version()" > /dev/null 2>&1; do + sleep 2 + COUNT=$((COUNT + 1)) + if [ $COUNT -ge $MAX_TRIES ]; then + echo "Failed to start MongoDB 5.0 after $MAX_TRIES attempts" + kill -9 $MONGO_PID 2>/dev/null || true + return 1 + fi + done + + # Set compatibility version to 4.4 + echo "Setting feature compatibility version to 4.4..." + if ! gosu mongodb mongo --quiet --eval 'db.adminCommand({setFeatureCompatibilityVersion: "4.4"})'; then + echo "Failed to set feature compatibility version" + kill -9 $MONGO_PID 2>/dev/null || true + return 1 + fi + + # Shutdown MongoDB 5.0 cleanly + echo "Shutting down MongoDB 5.0..." + gosu mongodb mongo --quiet --eval "db.adminCommand({shutdown: 1})" || kill $MONGO_PID + + # Wait for process to end + while kill -0 $MONGO_PID 2>/dev/null; do + sleep 1 + done + + # Run repair with MongoDB 4.4 + echo "Running repair with MongoDB 4.4..." + gosu mongodb /usr/bin/mongod --dbpath $MONGODB_DATA_DIR --repair + + return 0 + fi + return 0 +} + +# Try migration up to 3 times +MAX_MIGRATION_ATTEMPTS=3 +MIGRATION_ATTEMPT=1 + +while [ $MIGRATION_ATTEMPT -le $MAX_MIGRATION_ATTEMPTS ]; do + echo "Migration attempt $MIGRATION_ATTEMPT of $MAX_MIGRATION_ATTEMPTS" + if check_mongo_version; then + break + fi + MIGRATION_ATTEMPT=$((MIGRATION_ATTEMPT + 1)) + if [ $MIGRATION_ATTEMPT -le $MAX_MIGRATION_ATTEMPTS ]; then + echo "Migration failed, waiting before retry..." + sleep 5 + fi +done + +if [ $MIGRATION_ATTEMPT -gt $MAX_MIGRATION_ATTEMPTS ]; then + echo "Migration failed after $MAX_MIGRATION_ATTEMPTS attempts" + exit 1 +fi + +# Start MongoDB 4.4 normally +echo "Starting MongoDB 4.4..." +gosu mongodb /usr/bin/mongod --dbpath $MONGODB_DATA_DIR --logpath $MONGODB_LOG_DIR/mongodb.log --bind_ip 0.0.0.0 & +MONGO_PID=$! # 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 +MAX_TRIES=30 +COUNT=0 +while ! gosu mongodb mongo --quiet --eval "db.version()" > /dev/null 2>&1; do + sleep 2 + COUNT=$((COUNT + 1)) + if [ $COUNT -ge $MAX_TRIES ]; then + echo "Failed to start MongoDB. Checking logs:" + cat $MONGODB_LOG_DIR/mongodb.log + exit 1 + fi + echo "Waiting for MongoDB... (attempt $COUNT/$MAX_TRIES)" done -echo "MongoDB has started" +echo "MongoDB started successfully" # Start nginx echo "Starting nginx..." nginx -# Change to app directory +# Start backend services +echo "Starting backend services..." cd /app +export NODE_ENV=production -# Start the SSH service -echo "Starting SSH service..." -node src/backend/ssh.cjs & +# Start SSH service +su -s /bin/bash node -c "node src/backend/ssh.cjs" & -# Start the database service -echo "Starting database service..." -node src/backend/database.cjs & +# Start database service +su -s /bin/bash node -c "node src/backend/database.cjs" & -# Keep the container running and show MongoDB logs -echo "All services started. Tailing MongoDB logs..." +echo "All services started" + +# Keep container running and show logs tail -f $MONGODB_LOG_DIR/mongodb.log \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7615c7d0..43a23c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@fontsource/inter": "^5.1.1", "@mui/icons-material": "^6.4.7", "@mui/joy": "^5.0.0-beta.51", - "@tailwindcss/vite": "^4.0.8", + "@tailwindcss/vite": "^4.0.15", "@tiptap/extension-link": "^2.11.5", "@tiptap/pm": "^2.11.5", "@tiptap/react": "^2.11.5", @@ -29,6 +29,7 @@ "express": "^4.21.2", "is-stream": "^4.0.1", "make-dir": "^5.0.0", + "mitt": "^3.0.1", "mongoose": "^8.12.1", "node-ssh": "^13.2.0", "prop-types": "^15.8.1", @@ -41,7 +42,7 @@ "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "ssh2": "^1.16.0", - "tailwindcss": "^4.0.8" + "tailwindcss": "^4.0.15" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -1225,15 +1226,6 @@ "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", @@ -2011,42 +2003,42 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.8.tgz", - "integrity": "sha512-FKArQpbrbwv08TNT0k7ejYXpF+R8knZFAatNc0acOxbgeqLzwb86r+P3LGOjIeI3Idqe9CVkZrh4GlsJLJKkkw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.15.tgz", + "integrity": "sha512-IODaJjNmiasfZX3IoS+4Em3iu0fD2HS0/tgrnkYfW4hyUor01Smnr5eY3jc4rRgaTDrJlDmBTHbFO0ETTDaxWA==", "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", - "tailwindcss": "4.0.8" + "tailwindcss": "4.0.15" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.8.tgz", - "integrity": "sha512-KfMcuAu/Iw+DcV1e8twrFyr2yN8/ZDC/odIGta4wuuJOGkrkHZbvJvRNIbQNhGh7erZTYV6Ie0IeD6WC9Y8Hcw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.15.tgz", + "integrity": "sha512-e0uHrKfPu7JJGMfjwVNyt5M0u+OP8kUmhACwIRlM+JNBuReDVQ63yAD1NWe5DwJtdaHjugNBil76j+ks3zlk6g==", "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.8", - "@tailwindcss/oxide-darwin-arm64": "4.0.8", - "@tailwindcss/oxide-darwin-x64": "4.0.8", - "@tailwindcss/oxide-freebsd-x64": "4.0.8", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.8", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.8", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.8", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.8", - "@tailwindcss/oxide-linux-x64-musl": "4.0.8", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.8", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.8" + "@tailwindcss/oxide-android-arm64": "4.0.15", + "@tailwindcss/oxide-darwin-arm64": "4.0.15", + "@tailwindcss/oxide-darwin-x64": "4.0.15", + "@tailwindcss/oxide-freebsd-x64": "4.0.15", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.15", + "@tailwindcss/oxide-linux-arm64-gnu": "4.0.15", + "@tailwindcss/oxide-linux-arm64-musl": "4.0.15", + "@tailwindcss/oxide-linux-x64-gnu": "4.0.15", + "@tailwindcss/oxide-linux-x64-musl": "4.0.15", + "@tailwindcss/oxide-win32-arm64-msvc": "4.0.15", + "@tailwindcss/oxide-win32-x64-msvc": "4.0.15" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.8.tgz", - "integrity": "sha512-We7K79+Sm4mwJHk26Yzu/GAj7C7myemm7PeXvpgMxyxO70SSFSL3uCcqFbz9JA5M5UPkrl7N9fkBe/Y0iazqpA==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.15.tgz", + "integrity": "sha512-EBuyfSKkom7N+CB3A+7c0m4+qzKuiN0WCvzPvj5ZoRu4NlQadg/mthc1tl5k9b5ffRGsbDvP4k21azU4VwVk3Q==", "cpu": [ "arm64" ], @@ -2060,9 +2052,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.8.tgz", - "integrity": "sha512-Lv9Isi2EwkCTG1sRHNDi0uRNN1UGFdEThUAGFrydRmQZnraGLMjN8gahzg2FFnOizDl7LB2TykLUuiw833DSNg==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.15.tgz", + "integrity": "sha512-ObVAnEpLepMhV9VoO0JSit66jiN5C4YCqW3TflsE9boo2Z7FIjV80RFbgeL2opBhtxbaNEDa6D0/hq/EP03kgQ==", "cpu": [ "arm64" ], @@ -2076,9 +2068,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.8.tgz", - "integrity": "sha512-fWfywfYIlSWtKoqWTjukTHLWV3ARaBRjXCC2Eo0l6KVpaqGY4c2y8snUjp1xpxUtpqwMvCvFWFaleMoz1Vhzlw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.15.tgz", + "integrity": "sha512-IElwoFhUinOr9MyKmGTPNi1Rwdh68JReFgYWibPWTGuevkHkLWKEflZc2jtI5lWZ5U9JjUnUfnY43I4fEXrc4g==", "cpu": [ "x64" ], @@ -2092,9 +2084,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.8.tgz", - "integrity": "sha512-SO+dyvjJV9G94bnmq2288Ke0BIdvrbSbvtPLaQdqjqHR83v5L2fWADyFO+1oecHo9Owsk8MxcXh1agGVPIKIqw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.15.tgz", + "integrity": "sha512-6BLLqyx7SIYRBOnTZ8wgfXANLJV5TQd3PevRJZp0vn42eO58A2LykRKdvL1qyPfdpmEVtF+uVOEZ4QTMqDRAWA==", "cpu": [ "x64" ], @@ -2108,9 +2100,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.8.tgz", - "integrity": "sha512-ZSHggWiEblQNV69V0qUK5vuAtHP+I+S2eGrKGJ5lPgwgJeAd6GjLsVBN+Mqn2SPVfYM3BOpS9jX/zVg9RWQVDQ==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.15.tgz", + "integrity": "sha512-Zy63EVqO9241Pfg6G0IlRIWyY5vNcWrL5dd2WAKVJZRQVeolXEf1KfjkyeAAlErDj72cnyXObEZjMoPEKHpdNw==", "cpu": [ "arm" ], @@ -2124,9 +2116,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.8.tgz", - "integrity": "sha512-xWpr6M0OZLDNsr7+bQz+3X7zcnDJZJ1N9gtBWCtfhkEtDjjxYEp+Lr5L5nc/yXlL4MyCHnn0uonGVXy3fhxaVA==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.15.tgz", + "integrity": "sha512-2NemGQeaTbtIp1Z2wyerbVEJZTkAWhMDOhhR5z/zJ75yMNf8yLnE+sAlyf6yGDNr+1RqvWrRhhCFt7i0CIxe4Q==", "cpu": [ "arm64" ], @@ -2140,9 +2132,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.8.tgz", - "integrity": "sha512-5tz2IL7LN58ssGEq7h/staD7pu/izF/KeMWdlJ86WDe2Ah46LF3ET6ZGKTr5eZMrnEA0M9cVFuSPprKRHNgjeg==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.15.tgz", + "integrity": "sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==", "cpu": [ "arm64" ], @@ -2156,9 +2148,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.8.tgz", - "integrity": "sha512-KSzMkhyrxAQyY2o194NKVKU9j/c+NFSoMvnHWFaNHKi3P1lb+Vq1UC19tLHrmxSkKapcMMu69D7+G1+FVGNDXQ==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.15.tgz", + "integrity": "sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==", "cpu": [ "x64" ], @@ -2172,9 +2164,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.8.tgz", - "integrity": "sha512-yFYKG5UtHTRimjtqxUWXBgI4Tc6NJe3USjRIVdlTczpLRxq/SFwgzGl5JbatCxgSRDPBFwRrNPxq+ukfQFGdrw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.15.tgz", + "integrity": "sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==", "cpu": [ "x64" ], @@ -2188,9 +2180,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.8.tgz", - "integrity": "sha512-tndGujmCSba85cRCnQzXgpA2jx5gXimyspsUYae5jlPyLRG0RjXbDshFKOheVXU4TLflo7FSG8EHCBJ0EHTKdQ==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.15.tgz", + "integrity": "sha512-7QtSSJwYZ7ZK1phVgcNZpuf7c7gaCj8Wb0xjliligT5qCGCp79OV2n3SJummVZdw4fbTNKUOYMO7m1GinppZyA==", "cpu": [ "arm64" ], @@ -2204,9 +2196,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.8.tgz", - "integrity": "sha512-T77jroAc0p4EHVVgTUiNeFn6Nj3jtD3IeNId2X+0k+N1XxfNipy81BEkYErpKLiOkNhpNFjPee8/ZVas29b2OQ==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.15.tgz", + "integrity": "sha512-JQ5H+5MLhOjpgNp6KomouE0ZuKmk3hO5h7/ClMNAQ8gZI2zkli3IH8ZqLbd2DVfXDbdxN2xvooIEeIlkIoSCqw==", "cpu": [ "x64" ], @@ -2220,15 +2212,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.8.tgz", - "integrity": "sha512-+SAq44yLzYlzyrb7QTcFCdU8Xa7FOA0jp+Xby7fPMUie+MY9HhJysM7Vp+vL8qIp8ceQJfLD+FjgJuJ4lL6nyg==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.15.tgz", + "integrity": "sha512-JRexava80NijI8cTcLXNM3nQL5A0ptTHI8oJLLe8z1MpNB6p5J4WCdJJP8RoyHu8/eB1JzEdbpH86eGfbuaezQ==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.0.8", - "@tailwindcss/oxide": "4.0.8", - "lightningcss": "^1.29.1", - "tailwindcss": "4.0.8" + "@tailwindcss/node": "4.0.15", + "@tailwindcss/oxide": "4.0.15", + "lightningcss": "1.29.2", + "tailwindcss": "4.0.15" }, "peerDependencies": { "vite": "^5.2.0 || ^6" @@ -3932,15 +3924,12 @@ } }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "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", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/doctrine": { @@ -5880,12 +5869,12 @@ } }, "node_modules/lightningcss": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", - "integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", "license": "MPL-2.0", "dependencies": { - "detect-libc": "^1.0.3" + "detect-libc": "^2.0.3" }, "engines": { "node": ">= 12.0.0" @@ -5895,22 +5884,22 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.1", - "lightningcss-darwin-x64": "1.29.1", - "lightningcss-freebsd-x64": "1.29.1", - "lightningcss-linux-arm-gnueabihf": "1.29.1", - "lightningcss-linux-arm64-gnu": "1.29.1", - "lightningcss-linux-arm64-musl": "1.29.1", - "lightningcss-linux-x64-gnu": "1.29.1", - "lightningcss-linux-x64-musl": "1.29.1", - "lightningcss-win32-arm64-msvc": "1.29.1", - "lightningcss-win32-x64-msvc": "1.29.1" + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", - "integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", "cpu": [ "arm64" ], @@ -5928,9 +5917,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", - "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", "cpu": [ "x64" ], @@ -5948,9 +5937,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", - "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", "cpu": [ "x64" ], @@ -5968,9 +5957,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", - "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", "cpu": [ "arm" ], @@ -5988,9 +5977,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", - "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", "cpu": [ "arm64" ], @@ -6008,9 +5997,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", - "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", "cpu": [ "arm64" ], @@ -6028,9 +6017,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", - "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", "cpu": [ "x64" ], @@ -6048,9 +6037,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", - "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", "cpu": [ "x64" ], @@ -6068,9 +6057,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", - "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", "cpu": [ "arm64" ], @@ -6088,9 +6077,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", - "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", "cpu": [ "x64" ], @@ -6341,6 +6330,12 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -8218,9 +8213,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.8.tgz", - "integrity": "sha512-Me7N5CKR+D2A1xdWA5t5+kjjT7bwnxZOE6/yDI/ixJdJokszsn2n++mdU5yJwrsTpqFX2B9ZNMBJDwcqk9C9lw==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.15.tgz", + "integrity": "sha512-6ZMg+hHdMJpjpeCCFasX7K+U615U9D+7k5/cDK/iRwl6GptF24+I/AbKgOnXhVKePzrEyIXutLv36n4cRsq3Sg==", "license": "MIT" }, "node_modules/tapable": { diff --git a/package.json b/package.json index 0ab6013a..0d46cd7b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@fontsource/inter": "^5.1.1", "@mui/icons-material": "^6.4.7", "@mui/joy": "^5.0.0-beta.51", - "@tailwindcss/vite": "^4.0.8", + "@tailwindcss/vite": "^4.0.15", "@tiptap/extension-link": "^2.11.5", "@tiptap/pm": "^2.11.5", "@tiptap/react": "^2.11.5", @@ -31,6 +31,7 @@ "express": "^4.21.2", "is-stream": "^4.0.1", "make-dir": "^5.0.0", + "mitt": "^3.0.1", "mongoose": "^8.12.1", "node-ssh": "^13.2.0", "prop-types": "^15.8.1", @@ -43,7 +44,7 @@ "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "ssh2": "^1.16.0", - "tailwindcss": "^4.0.8" + "tailwindcss": "^4.0.15" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/src/App.jsx b/src/App.jsx index cd6caadf..48298a51 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,7 +2,7 @@ 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 AuthModal from "./modals/AuthModal.jsx"; import { Button } from "@mui/joy"; import { CssVarsProvider } from "@mui/joy"; import theme from "./theme"; @@ -12,16 +12,15 @@ 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"; +import eventBus from "./other/eventBus.jsx"; function App() { const [isAddHostHidden, setIsAddHostHidden] = useState(true); - const [isLoginUserHidden, setIsLoginUserHidden] = useState(true); - const [isCreateUserHidden, setIsCreateUserHidden] = useState(true); + const [isAuthModalHidden, setIsAuthModalHidden] = useState(true); const [isProfileHidden, setIsProfileHidden] = useState(true); const [isErrorHidden, setIsErrorHidden] = useState(true); const [errorMessage, setErrorMessage] = useState(''); @@ -35,10 +34,17 @@ function App() { ip: "", user: "", password: "", + sshKey: "", port: 22, authMethod: "Select Auth", - rememberHost: false, + rememberHost: true, storePassword: true, + connectionType: "ssh", + rdpDomain: "", + rdpWindowsAuthentication: true, + rdpConsole: false, + vncScaling: "100%", + vncQuality: "High" }); const [editHostForm, setEditHostForm] = useState({ name: "", @@ -46,6 +52,7 @@ function App() { ip: "", user: "", password: "", + sshKey: "", port: 22, authMethod: "Select Auth", rememberHost: true, @@ -53,23 +60,23 @@ function App() { }); const [isNoAuthHidden, setIsNoAuthHidden] = useState(true); const [authForm, setAuthForm] = useState({ - password: "", - rsaKey: "", - }); - const [loginUserForm, setLoginUserForm] = useState({ - username: "", - password: "", - }); - const [createUserForm, setCreateUserForm] = useState({ - username: "", - password: "", + username: '', + password: '', + confirmPassword: '' }); + const [noAuthenticationForm, setNoAuthenticationForm] = useState({ + authMethod: 'Select Auth', + password: '', + sshKey: '', + keyType: '', + }) 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); + const [isHostViewerMenuOpen, setIsHostViewerMenuOpen] = useState(null); useEffect(() => { const handleKeyDown = (e) => { @@ -133,13 +140,13 @@ function App() { if (userRef.current?.getUser()) { setIsLoggingIn(false); - setIsLoginUserHidden(true); + setIsAuthModalHidden(true); return; } if (!sessionToken) { setIsLoggingIn(false); - setIsLoginUserHidden(false); + setIsAuthModalHidden(false); return; } @@ -147,13 +154,13 @@ function App() { let loginAttempts = 0; const maxAttempts = 50; let attemptLoginInterval; - + const loginTimeout = setTimeout(() => { if (isComponentMounted) { clearInterval(attemptLoginInterval); if (!userRef.current?.getUser()) { localStorage.removeItem('sessionToken'); - setIsLoginUserHidden(false); + setIsAuthModalHidden(false); setIsLoggingIn(false); setErrorMessage('Login timed out. Please try again.'); setIsErrorHidden(false); @@ -163,14 +170,14 @@ function App() { 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); + setIsAuthModalHidden(false); setIsLoggingIn(false); setErrorMessage('Login timed out. Please try again.'); setIsErrorHidden(false); @@ -186,7 +193,7 @@ function App() { if (isComponentMounted) { clearTimeout(loginTimeout); clearInterval(attemptLoginInterval); - setIsLoginUserHidden(true); + setIsAuthModalHidden(true); setIsLoggingIn(false); setIsErrorHidden(true); } @@ -200,7 +207,7 @@ function App() { localStorage.removeItem('sessionToken'); setErrorMessage(`Auto-login failed: ${error}`); setIsErrorHidden(false); - setIsLoginUserHidden(false); + setIsAuthModalHidden(false); setIsLoggingIn(false); } } @@ -222,34 +229,53 @@ function App() { }, []); const handleAddHost = () => { - if (addHostForm.ip && addHostForm.user && addHostForm.port) { + if (addHostForm.ip && addHostForm.port) { + if (addHostForm.connectionType === 'ssh' && !addHostForm.user) { + setErrorMessage("Please fill out all required fields (IP, User, Port)."); + setIsErrorHidden(false); + return; + } + if (!addHostForm.rememberHost) { connectToHost(); setIsAddHostHidden(true); return; } - if (addHostForm.authMethod === 'Select Auth') { - alert("Please select an authentication method."); - return; + if (addHostForm.connectionType === 'ssh') { + if (addHostForm.authMethod === 'Select Auth') { + setErrorMessage("Please select an authentication method."); + setIsErrorHidden(false); + return; + } + if (addHostForm.authMethod === 'password' && !addHostForm.password) { + setIsNoAuthHidden(false); + return; + } + if (addHostForm.authMethod === 'sshKey' && !addHostForm.sshKey) { + setIsNoAuthHidden(false); + return; + } } - if (addHostForm.authMethod === 'password' && !addHostForm.password) { - setIsNoAuthHidden(false); - return; - } - if (addHostForm.authMethod === 'rsaKey' && !addHostForm.rsaKey) { + else if (!addHostForm.password) { setIsNoAuthHidden(false); return; } - connectToHost(); - if (!addHostForm.storePassword) { - addHostForm.password = ''; + try { + connectToHost(); + if (!addHostForm.storePassword) { + addHostForm.password = ''; + } + handleSaveHost(); + setIsAddHostHidden(true); + } catch (error) { + setErrorMessage(error.message || "Failed to add host"); + setIsErrorHidden(false); } - handleSaveHost(); - setIsAddHostHidden(true); } else { - alert("Please fill out all required fields (IP, User, Port)."); + setErrorMessage("Please fill out all required fields."); + setIsErrorHidden(false); } }; @@ -261,7 +287,7 @@ function App() { 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, + sshKey: addHostForm.rememberHost && addHostForm.authMethod === 'sshKey' ? addHostForm.sshKey : undefined, }; const newTerminal = { @@ -274,25 +300,42 @@ function App() { setActiveTab(nextId); setNextId(nextId + 1); setIsAddHostHidden(true); - setAddHostForm({ name: "", folder: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth", rememberHost: false, storePassword: true }); + setAddHostForm({ name: "", folder: "", ip: "", user: "", password: "", sshKey: "", port: 22, authMethod: "Select Auth", rememberHost: true, storePassword: true, connectionType: "ssh", rdpDomain: "", rdpWindowsAuthentication: true, rdpConsole: false, vncScaling: "100%", vncQuality: "High" }); } const handleAuthSubmit = (form) => { - const updatedTerminals = terminals.map((terminal) => { - if (terminal.id === activeTab) { - return { - ...terminal, - hostConfig: { - ...terminal.hostConfig, - password: form.password, - rsaKey: form.rsaKey + try { + setIsNoAuthHidden(true); + + setTimeout(() => { + const updatedTerminals = terminals.map((terminal) => { + if (terminal.id === activeTab) { + return { + ...terminal, + hostConfig: { + ...terminal.hostConfig, + password: form.authMethod === 'password' ? form.password : undefined, + sshKey: form.authMethod === 'sshKey' ? form.sshKey : undefined + } + }; } - }; - } - return terminal; - }); - setTerminals(updatedTerminals); - setIsNoAuthHidden(true); + return terminal; + }); + + setTerminals(updatedTerminals); + + setNoAuthenticationForm({ + authMethod: 'Select Auth', + password: '', + sshKey: '', + keyType: '', + }); + }, 100); + } catch (error) { + console.error("Authentication error:", error); + setErrorMessage("Failed to authenticate: " + (error.message || "Unknown error")); + setIsErrorHidden(false); + } }; const connectToHostWithConfig = (hostConfig) => { @@ -311,7 +354,7 @@ function App() { user: hostConfig.user.trim(), port: hostConfig.port || '22', password: hostConfig.password?.trim(), - rsaKey: hostConfig.rsaKey?.trim(), + sshKey: hostConfig.sshKey?.trim(), }; const newTerminal = { @@ -326,20 +369,30 @@ function App() { 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 handleSaveHost = async () => { + try { + let hostConfig = { + name: addHostForm.name || addHostForm.ip, + folder: addHostForm.folder, + ip: addHostForm.ip, + user: addHostForm.user, + password: (addHostForm.authMethod === 'password' || addHostForm.connectionType === 'vnc' || addHostForm.connectionType === 'rdp') ? addHostForm.password : undefined, + sshKey: addHostForm.connectionType === 'ssh' && addHostForm.authMethod === 'sshKey' ? addHostForm.sshKey : undefined, + port: String(addHostForm.port), + connectionType: addHostForm.connectionType, + rdpDomain: addHostForm.connectionType === 'rdp' ? addHostForm.rdpDomain : undefined, + rdpWindowsAuthentication: addHostForm.connectionType === 'rdp' ? addHostForm.rdpWindowsAuthentication : undefined, + rdpConsole: addHostForm.connectionType === 'rdp' ? addHostForm.rdpConsole : undefined, + vncScaling: addHostForm.connectionType === 'vnc' ? addHostForm.vncScaling : undefined, + vncQuality: addHostForm.connectionType === 'vnc' ? addHostForm.vncQuality : undefined + } + if (userRef.current) { + await userRef.current.saveHost({ + hostConfig, + }); + } + } catch (error) { + throw error; } } @@ -349,13 +402,13 @@ function App() { userRef.current.loginUser({ sessionToken, onSuccess: () => { - setIsLoginUserHidden(true); + setIsAuthModalHidden(true); setIsLoggingIn(false); if (onSuccess) onSuccess(); }, onFailure: (error) => { localStorage.removeItem('sessionToken'); - setIsLoginUserHidden(false); + setIsAuthModalHidden(false); setIsLoggingIn(false); if (onFailure) onFailure(error); }, @@ -365,12 +418,12 @@ function App() { username, password, onSuccess: () => { - setIsLoginUserHidden(true); + setIsAuthModalHidden(true); setIsLoggingIn(false); if (onSuccess) onSuccess(); }, onFailure: (error) => { - setIsLoginUserHidden(false); + setIsAuthModalHidden(false); setIsLoggingIn(false); if (onFailure) onFailure(error); }, @@ -446,7 +499,7 @@ function App() { if (newConfig) { if (isEditing) return; setIsEditing(true); - + try { await userRef.current.editHost({ oldHostConfig: oldConfig, @@ -454,9 +507,11 @@ function App() { }); await new Promise(resolve => setTimeout(resolve, 3000)); + setIsEditHostHidden(true); + } catch (error) { + throw error; } finally { setIsEditing(false); - setIsEditHostHidden(true); } return; } @@ -464,7 +519,7 @@ function App() { updateEditHostForm(oldConfig); } catch (error) { console.error('Edit failed:', error); - setErrorMessage(`Edit failed: ${error}`); + setErrorMessage(`Edit failed: ${error.message || error}`); setIsErrorHidden(false); setIsEditing(false); } @@ -585,7 +640,7 @@ function App() { {/* Profile Button */} - {isOwner && ( - <> - - - - - )} - {!isOwner && ( - - )} + { + e.stopPropagation(); + setSelectedHost(hostWrapper); + setIsMenuOpen(!isMenuOpen); + anchorEl.current = e.currentTarget; + }} + disabled={isDeleting} + sx={{ + backgroundColor: "#6e6e6e", + "&:hover": { backgroundColor: "#0f0f0f" }, + opacity: isDeleting ? 0.5 : 1, + cursor: isDeleting ? "not-allowed" : "pointer", + borderColor: "#3d3d3d", + borderWidth: "2px", + color: "#fff", + }} + > + â‹® + ); @@ -352,7 +344,6 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e return ( <> - {/* Render hosts without folders first */}
handleDragOver(e, 'no-folder')} @@ -362,7 +353,6 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e {noFolder.map((host) => renderHostItem(host))}
- {/* Render folders and their hosts */} {sortedFolders.map((folderName) => (
+ { + setIsMenuOpen(false); + setSelectedHost(null); + }} + sx={{ + "& .MuiMenu-list": { + backgroundColor: "#6e6e6e", + color: "white" + } + }} + > + {selectedHost && ( + selectedHost.createdBy?._id === userRef.current?.getUser()?.id ? ( + <> + { + e.stopPropagation(); + setSelectedHostForShare(selectedHost); + setIsShareModalHidden(false); + setIsMenuOpen(false); + }} + > + Share + + { + e.stopPropagation(); + openEditPanel(selectedHost.config); + setIsMenuOpen(false); + }} + > + Edit + + { + e.stopPropagation(); + handleDelete(e, selectedHost); + setIsMenuOpen(false); + }} + disabled={isDeleting} + > + {isDeleting ? "Deleting..." : "Delete"} + + + ) : ( + { + e.stopPropagation(); + handleDelete(e, selectedHost); + setIsMenuOpen(false); + }} + disabled={isDeleting} + > + {isDeleting ? "Removing..." : "Remove Share"} + + ) + )} +
); } @@ -418,6 +470,8 @@ HostViewer.propTypes = { onModalOpen: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired, userRef: PropTypes.object.isRequired, + isMenuOpen: PropTypes.bool.isRequired, + setIsMenuOpen: PropTypes.func.isRequired, }; export default HostViewer; \ No newline at end of file diff --git a/src/apps/ssh/Terminal.jsx b/src/apps/ssh/Terminal.jsx index b50f90f3..0b367157 100644 --- a/src/apps/ssh/Terminal.jsx +++ b/src/apps/ssh/Terminal.jsx @@ -6,7 +6,7 @@ import io from "socket.io-client"; import PropTypes from "prop-types"; import theme from "../../theme.js"; -export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidden }, ref) => { +export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidden, setErrorMessage, setIsErrorHidden }, ref) => { const terminalRef = useRef(null); const socketRef = useRef(null); const fitAddon = useRef(new FitAddon()); @@ -18,8 +18,8 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde if (!parentContainer || parentContainer.clientWidth === 0) return; - const parentWidth = parentContainer.clientWidth - 10; - const parentHeight = parentContainer.clientHeight - 10; + const parentWidth = parentContainer.clientWidth - 8; + const parentHeight = parentContainer.clientHeight - 12; terminalContainer.style.width = `${parentWidth}px`; terminalContainer.style.height = `${parentHeight}px`; @@ -50,6 +50,9 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde fontSize: 14, scrollback: 1000, ignoreBracketedPasteMode: true, + letterSpacing: 0, + lineHeight: 1, + padding: 2, }); terminalInstance.current.loadAddon(fitAddon.current); @@ -62,21 +65,28 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde { path: "/ssh.io/socket.io", transports: ["websocket", "polling"], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + timeout: 20000, } ); socketRef.current = socket; socket.on("connect_error", (error) => { terminalInstance.current.write(`\r\n*** Socket connection error: ${error.message} ***\r\n`); + console.error("Socket connection error:", error); }); socket.on("connect_timeout", () => { terminalInstance.current.write(`\r\n*** Socket connection timeout ***\r\n`); + console.error("Socket connection timeout"); }); socket.on("error", (err) => { + console.error("SSH connection error:", err); const isAuthError = err.toLowerCase().includes("authentication") || err.toLowerCase().includes("auth"); - if (isAuthError && !hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) { + if (isAuthError && !hostConfig.password?.trim() && !hostConfig.sshKey?.trim() && !authModalShown) { authModalShown = true; setIsNoAuthHidden(false); } @@ -88,7 +98,7 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde resizeTerminal(); const { cols, rows } = terminalInstance.current; - if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim()) { + if (!hostConfig.password?.trim() && !hostConfig.sshKey?.trim()) { setIsNoAuthHidden(false); return; } @@ -98,16 +108,19 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde user: hostConfig.user, port: Number(hostConfig.port) || 22, password: hostConfig.password?.trim(), - rsaKey: hostConfig.rsaKey?.trim() + sshKey: hostConfig.sshKey?.trim(), + rsaKey: hostConfig.sshKey?.trim() || hostConfig.rsaKey?.trim(), }; socket.emit("connectToHost", cols, rows, sshConfig); }); setTimeout(() => { - fitAddon.current.fit(); - resizeTerminal(); - terminalInstance.current.focus(); + if (terminalInstance.current) { + fitAddon.current.fit(); + resizeTerminal(); + terminalInstance.current.focus(); + } }, 50); socket.on("data", (data) => { @@ -117,68 +130,85 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde let isPasting = false; - terminalInstance.current.onData((data) => { - socketRef.current.emit("data", data); - }); - - terminalInstance.current.attachCustomKeyEventHandler((event) => { - if ((event.ctrlKey || event.metaKey) && event.key === "v") { - if (isPasting) return false; - isPasting = true; - - event.preventDefault(); - - navigator.clipboard.readText().then((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; - }); - - terminalInstance.current.onKey(({ domEvent }) => { - if (domEvent.key === "c" && (domEvent.ctrlKey || domEvent.metaKey)) { - const selection = terminalInstance.current.getSelection(); - if (selection) { - navigator.clipboard.writeText(selection); + if (terminalInstance.current) { + terminalInstance.current.onData((data) => { + if (socketRef.current && socketRef.current.connected) { + socketRef.current.emit("data", data); } - } - }); + }); + + terminalInstance.current.attachCustomKeyEventHandler((event) => { + if ((event.ctrlKey || event.metaKey) && event.key === "v") { + if (isPasting) return false; + isPasting = true; + + event.preventDefault(); + navigator.clipboard.readText().then(text => { + if (text && socketRef.current?.connected) { + const processedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\r"); + socketRef.current.emit("data", processedText); + } + }).catch(() => { + setErrorMessage("Paste failed: Clipboard access denied. Instead, use Control Shift V."); + setIsErrorHidden(false); + }).finally(() => { + setTimeout(() => { + isPasting = false; + }, 300); + }); + return false; + } + return true; + }); + + terminalInstance.current.onKey(({ domEvent }) => { + if (domEvent.key === "c" && (domEvent.ctrlKey || domEvent.metaKey)) { + const selection = terminalInstance.current.getSelection(); + if (selection) { + navigator.clipboard.writeText(selection); + } + } + }); + } let authModalShown = false; socket.on("noAuthRequired", () => { - if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) { + if (!hostConfig.password?.trim() && !hostConfig.sshKey?.trim() && !authModalShown) { authModalShown = true; setIsNoAuthHidden(false); } }); + socket.on("disconnect", (reason) => { + if (terminalInstance.current) { + terminalInstance.current.write(`\r\n*** Socket disconnected: ${reason} ***\r\n`); + } + }); + + socket.on("reconnect", (attemptNumber) => { + if (terminalInstance.current) { + terminalInstance.current.write(`\r\n*** Socket reconnected after ${attemptNumber} attempts ***\r\n`); + } + }); + + socket.on("reconnect_error", (error) => { + console.error("Socket reconnect error:", error); + if (terminalInstance.current) { + terminalInstance.current.write(`\r\n*** Socket reconnect error: ${error.message} ***\r\n`); + } + }); + + const pingInterval = setInterval(() => { + if (socketRef.current && socketRef.current.connected) { + socketRef.current.emit("ping"); + } + }, 5000); + + socketRef.current.on("pong", () => {}); + return () => { + clearInterval(pingInterval); if (terminalInstance.current) { terminalInstance.current.dispose(); terminalInstance.current = null; @@ -202,14 +232,21 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde const parentContainer = terminalContainer.parentElement; if (!parentContainer) return; - const observer = new ResizeObserver(() => { + const resizeObserver = new ResizeObserver(() => { resizeTerminal(); }); - observer.observe(parentContainer); + resizeObserver.observe(parentContainer); + + const handleWindowResize = () => { + resizeTerminal(); + }; + + window.addEventListener('resize', handleWindowResize); return () => { - observer.disconnect(); + resizeObserver.disconnect(); + window.removeEventListener('resize', handleWindowResize); }; }, []); @@ -222,7 +259,7 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde position: 'absolute', width: '100%', height: '100%', - transform: 'translateY(5px) translateX(5px)', + transform: 'translateY(2px) translateX(3px)', }} /> ); @@ -235,9 +272,12 @@ NewTerminal.propTypes = { ip: PropTypes.string.isRequired, user: PropTypes.string.isRequired, password: PropTypes.string, + sshKey: PropTypes.string, rsaKey: PropTypes.string, port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, }).isRequired, isVisible: PropTypes.bool.isRequired, setIsNoAuthHidden: PropTypes.func.isRequired, + setErrorMessage: PropTypes.func.isRequired, + setIsErrorHidden: PropTypes.func.isRequired, }; \ No newline at end of file diff --git a/src/apps/user/User.jsx b/src/apps/user/User.jsx index b135a840..534ee227 100644 --- a/src/apps/user/User.jsx +++ b/src/apps/user/User.jsx @@ -149,6 +149,27 @@ export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSucce if (!currentUser.current) return onFailure("Not authenticated"); try { + const existingHosts = await getAllHosts(); + + const duplicateNameHost = existingHosts.find(host => + host.config.name && + host.config.name.toLowerCase() === hostConfig.hostConfig.name.toLowerCase() + ); + + if (duplicateNameHost) { + return onFailure("A host with this name already exists. Please choose a different name."); + } + + if (!hostConfig.hostConfig.name) { + const duplicateIpHost = existingHosts.find(host => + host.config.ip.toLowerCase() === hostConfig.hostConfig.ip.toLowerCase() + ); + + if (duplicateIpHost) { + return onFailure("A host with this IP already exists. Please provide a unique name."); + } + } + const response = await new Promise((resolve) => { socketRef.current.emit("saveHostConfig", { userId: currentUser.current.id, @@ -186,7 +207,7 @@ export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSucce user: host.config.user || '', port: host.config.port || '22', password: host.config.password || '', - rsaKey: host.config.rsaKey || '', + sshKey: host.config.sshKey || '', } : {} })).filter(host => host.config && host.config.ip && host.config.user); } else { @@ -222,7 +243,18 @@ export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSucce if (!currentUser.current) return onFailure("Not authenticated"); try { - console.log('Editing host with configs:', { oldHostConfig, newHostConfig }); + const existingHosts = await getAllHosts(); + + const duplicateNameHost = existingHosts.find(host => + host.config.name && + host.config.name.toLowerCase() === newHostConfig.name.toLowerCase() && + host.config.ip.toLowerCase() !== oldHostConfig.ip.toLowerCase() + ); + + if (duplicateNameHost) { + return onFailure("A host with this name already exists. Please choose a different name."); + } + const response = await new Promise((resolve) => { socketRef.current.emit("editHost", { userId: currentUser.current.id, diff --git a/src/backend/database.cjs b/src/backend/database.cjs index 1e453db3..2e3e4bb4 100644 --- a/src/backend/database.cjs +++ b/src/backend/database.cjs @@ -185,19 +185,36 @@ io.of('/database.io').on('connection', (socket) => { user: hostConfig.user.trim(), port: hostConfig.port || 22, password: hostConfig.password?.trim() || undefined, - rsaKey: hostConfig.rsaKey?.trim() || undefined + sshKey: hostConfig.sshKey?.trim() || undefined, }; const finalName = cleanConfig.name || cleanConfig.ip; - const existingHost = await Host.findOne({ - name: finalName, - createdBy: userId + // Check for hosts with the same name (case insensitive) + const existingHostByName = await Host.findOne({ + createdBy: userId, + name: { $regex: new RegExp('^' + finalName + '$', 'i') } }); - if (existingHost) { + if (existingHostByName) { logger.warn(`Host with name ${finalName} already exists for user: ${userId}`); - return callback({ error: 'Host with this name already exists' }); + return callback({ error: `Host with name "${finalName}" already exists. Please choose a different name.` }); + } + + // Prevent duplicate IPs if using IP as name + if (!cleanConfig.name) { + const existingHostByIp = await Host.findOne({ + createdBy: userId, + config: { $regex: new RegExp(cleanConfig.ip, 'i') } + }); + + if (existingHostByIp) { + const decryptedConfig = decryptData(existingHostByIp.config, userId, sessionToken); + if (decryptedConfig && decryptedConfig.ip.toLowerCase() === cleanConfig.ip.toLowerCase()) { + logger.warn(`Host with IP ${cleanConfig.ip} already exists for user: ${userId}`); + return callback({ error: `Host with IP "${cleanConfig.ip}" already exists. Please provide a unique name.` }); + } + } } const encryptedConfig = encryptData(cleanConfig, userId, sessionToken); @@ -397,6 +414,7 @@ io.of('/database.io').on('connection', (socket) => { return callback({ error: 'Invalid session' }); } + // Find the host to be edited const hosts = await Host.find({ createdBy: userId }); const host = hosts.find(h => { const decryptedConfig = decryptData(h.config, userId, sessionToken); @@ -408,6 +426,37 @@ io.of('/database.io').on('connection', (socket) => { return callback({ error: 'Host not found' }); } + const finalName = newHostConfig.name?.trim() || newHostConfig.ip.trim(); + + // If the name is being changed, check for duplicates using case-insensitive comparison + if (finalName.toLowerCase() !== host.name.toLowerCase()) { + // Check for duplicate name using regex for case-insensitive comparison + const duplicateNameHost = await Host.findOne({ + createdBy: userId, + _id: { $ne: host._id }, // Exclude the current host + name: { $regex: new RegExp('^' + finalName + '$', 'i') } + }); + + if (duplicateNameHost) { + logger.warn(`Host with name ${finalName} already exists for user: ${userId}`); + return callback({ error: `Host with name "${finalName}" already exists. Please choose a different name.` }); + } + } + + // If IP is changed and no custom name provided, check for duplicate IP + if (newHostConfig.ip !== oldHostConfig.ip && !newHostConfig.name) { + const duplicateIpHost = hosts.find(h => { + if (h._id.toString() === host._id.toString()) return false; + const decryptedConfig = decryptData(h.config, userId, sessionToken); + return decryptedConfig && decryptedConfig.ip.toLowerCase() === newHostConfig.ip.toLowerCase(); + }); + + if (duplicateIpHost) { + logger.warn(`Host with IP ${newHostConfig.ip} already exists for user: ${userId}`); + return callback({ error: `Host with IP "${newHostConfig.ip}" already exists. Please provide a unique name.` }); + } + } + const cleanConfig = { name: newHostConfig.name?.trim(), folder: newHostConfig.folder?.trim() || null, @@ -415,7 +464,7 @@ io.of('/database.io').on('connection', (socket) => { user: newHostConfig.user.trim(), port: newHostConfig.port || 22, password: newHostConfig.password?.trim() || undefined, - rsaKey: newHostConfig.rsaKey?.trim() || undefined + sshKey: newHostConfig.sshKey?.trim() || undefined, }; const encryptedConfig = encryptData(cleanConfig, userId, sessionToken); @@ -424,6 +473,7 @@ io.of('/database.io').on('connection', (socket) => { return callback({ error: 'Configuration encryption failed' }); } + host.name = finalName; host.config = encryptedConfig; host.folder = cleanConfig.folder; await host.save(); @@ -432,7 +482,7 @@ io.of('/database.io').on('connection', (socket) => { callback({ success: true }); } catch (error) { logger.error('Host edit error:', error); - callback({ error: 'Failed to edit host' }); + callback({ error: `Failed to edit host: ${error.message}` }); } }); diff --git a/src/backend/ssh.cjs b/src/backend/ssh.cjs index 0fd3ba16..775a6149 100644 --- a/src/backend/ssh.cjs +++ b/src/backend/ssh.cjs @@ -10,7 +10,10 @@ const io = socketIo(server, { methods: ["GET", "POST"], credentials: true }, - allowEIO3: true + allowEIO3: true, + pingInterval: 2500, + pingTimeout: 5000, + maxHttpBufferSize: 1e7, }); const logger = { @@ -32,7 +35,7 @@ io.on("connection", (socket) => { return; } - if (!hostConfig.password && !hostConfig.rsaKey) { + if (!hostConfig.password && !hostConfig.sshKey) { logger.error("No authentication provided"); socket.emit("error", "Authentication required"); return; @@ -42,18 +45,18 @@ io.on("connection", (socket) => { ip: hostConfig.ip, port: hostConfig.port, user: hostConfig.user, - authType: hostConfig.password ? 'password' : 'public key', + authType: hostConfig.password ? 'password' : 'key', }; logger.info("Connecting with config:", safeHostConfig); - const { ip, port, user, password, rsaKey } = hostConfig; + const { ip, port, user, password, sshKey, } = hostConfig; const conn = new SSHClient(); conn .on("ready", function () { logger.info("SSH connection established"); - conn.shell({ term: "xterm-256color" }, function (err, newStream) { + conn.shell({ term: "xterm-256color", keepaliveInterval: 30000 }, function (err, newStream) { if (err) { logger.error("Shell error:", err.message); socket.emit("error", err.message); @@ -93,12 +96,22 @@ io.on("connection", (socket) => { logger.error("Error:", err.message); socket.emit("error", err.message); }) + .on("ping", function () { + socket.emit("ping"); + }) .connect({ host: ip, port: port, username: user, - password: password, - privateKey: rsaKey ? Buffer.from(rsaKey) : undefined, + password: password || undefined, + privateKey: sshKey ? Buffer.from(sshKey) : undefined, + algorithms: { + kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256'], + serverHostKey: ['ssh-ed25519', 'ecdsa-sha2-nistp256'] + }, + keepaliveInterval: 10000, + keepaliveCountMax: 5, + readyTimeout: 5000, }); }); diff --git a/src/modals/AddHostModal.jsx b/src/modals/AddHostModal.jsx index 01880945..b65cc54f 100644 --- a/src/modals/AddHostModal.jsx +++ b/src/modals/AddHostModal.jsx @@ -7,8 +7,6 @@ import { FormLabel, Input, Stack, - DialogTitle, - DialogContent, ModalDialog, Select, Option, @@ -27,19 +25,51 @@ 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 [errorMessage, setErrorMessage] = useState(""); + const [showError, setShowError] = useState(false); 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 supportedKeyTypes = { + 'id_rsa': 'RSA', + 'id_ed25519': 'ED25519', + 'id_ecdsa': 'ECDSA', + 'id_dsa': 'DSA', + '.pem': 'PEM', + '.key': 'KEY', + '.ppk': 'PPK' + }; + + const isValidKeyFile = Object.keys(supportedKeyTypes).some(ext => + file.name.toLowerCase().includes(ext) || file.name.endsWith('.pub') + ); + + if (isValidKeyFile) { + const reader = new FileReader(); + reader.onload = (event) => { + const keyContent = event.target.result; + let keyType = 'UNKNOWN'; + + if (keyContent.includes('BEGIN RSA PRIVATE KEY') || keyContent.includes('BEGIN RSA PUBLIC KEY')) { + keyType = 'RSA'; + } else if (keyContent.includes('BEGIN OPENSSH PRIVATE KEY') && keyContent.includes('ssh-ed25519')) { + keyType = 'ED25519'; + } else if (keyContent.includes('BEGIN EC PRIVATE KEY') || keyContent.includes('BEGIN EC PUBLIC KEY')) { + keyType = 'ECDSA'; + } else if (keyContent.includes('BEGIN DSA PRIVATE KEY')) { + keyType = 'DSA'; + } + + setForm(prev => ({ + ...prev, + sshKey: keyContent, + keyType: keyType, + authMethod: 'sshKey' + })); + }; + reader.readAsText(file); + } else { + alert('Please upload a valid SSH key file (RSA, ED25519, ECDSA, DSA, PEM, or PPK format).'); } }; @@ -48,19 +78,25 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd ...prev, authMethod: newMethod, password: "", - rsaKey: "" + sshKey: "", + keyType: "", })); }; const isFormValid = () => { - if (!form.ip || !form.user || !form.port) return false; - const portNum = Number(form.port); + const { ip, user, port, authMethod, password, sshKey } = form; + + if (!ip?.trim() || !user?.trim() || !port) return false; + + const portNum = Number(port); if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false; + if (!form.rememberHost) return true; + 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; + if (authMethod === 'Select Auth') return false; + if (authMethod === 'password' && !password?.trim()) return false; + if (authMethod === 'sshKey' && !sshKey?.trim()) return false; } return true; @@ -68,26 +104,29 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd const handleSubmit = (event) => { event.preventDefault(); - if (isFormValid()) { - if (!form.rememberHost) { - handleAddHost(); - } else { - handleAddHost(); - } + + setErrorMessage(""); + setShowError(false); - setForm({ - name: '', - folder: '', - ip: '', - user: '', - password: '', - rsaKey: '', - port: 22, - authMethod: 'Select Auth', - rememberHost: false, - storePassword: true, - }); - setIsAddHostHidden(true); + if (!form.ip?.trim()) { + setErrorMessage("Please provide an IP address."); + setShowError(true); + return; + } + + if (form.connectionType === 'ssh' && !form.user?.trim()) { + setErrorMessage("Please provide a username for SSH connection."); + setShowError(true); + return; + } + + try { + handleAddHost(); + setActiveTab(0); + } catch (error) { + console.error("Add host error:", error); + setErrorMessage(error.message || "Failed to add host. The host name or IP may already exist."); + setShowError(true); } }; @@ -98,15 +137,18 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd display: 'flex', justifyContent: 'center', alignItems: 'center', + backdropFilter: 'blur(5px)', + backgroundColor: 'rgba(0, 0, 0, 0.2)', }} > - Add Host - -
- setActiveTab(val)} - sx={{ - backgroundColor: theme.palette.general.disabled, - borderRadius: '8px', - padding: '8px', - marginBottom: '16px', - width: '100%', - }} - > - + {errorMessage} +
+ )} + setActiveTab(val)} + sx={{ + width: '100%', + mb: 0, + backgroundColor: theme.palette.general.tertiary, + }} + > + - Basic Info - Connection - Authentication - + }, + }, + }} + > + Basic Info + Connection + Authentication + - - - - Host Name - setForm({ ...form, name: e.target.value })} - sx={{ - backgroundColor: theme.palette.general.primary, +
+ + + + Host Name + setForm({ ...form, name: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Folder + setForm({ ...form, folder: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Remember Host + setForm({ + ...form, + rememberHost: e.target.checked, + })} + sx={{ + color: theme.palette.text.primary, + '&.Mui-checked': { color: theme.palette.text.primary, - }} - /> - - - Folder - setForm({ ...form, folder: e.target.value })} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - + }, + }} + /> + + + - - - - Host IP - setForm({ ...form, ip: e.target.value })} - required - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - Host User - setForm({ ...form, user: e.target.value })} - required - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - 65535}> - Host Port - setForm({ ...form, port: e.target.value })} - min={1} - max={65535} - required - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - + + + + Host IP + setForm({ ...form, ip: e.target.value })} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Host User + setForm({ ...form, user: e.target.value })} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + 65535}> + Host Port + setForm({ ...form, port: e.target.value })} + min={1} + max={65535} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + - - + + + + Authentication Method + + + + {form.authMethod === 'password' && ( + + Password +
+ setForm({ ...form, password: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + flex: 1 + }} + /> + setShowPassword(!showPassword)} + sx={{ + color: theme.palette.text.primary, + marginLeft: 1 + }} + > + {showPassword ? : } + +
+
+ )} + + {form.authMethod === 'sshKey' && ( + + + SSH Key + + + + )} + + {form.rememberHost && ( - Remember Host + Store Password setForm({ - ...form, - rememberHost: e.target.checked, - - ...((!e.target.checked) && { - authMethod: 'Select Auth', - password: '', - rsaKey: '', - storePassword: true - }) - })} + checked={Boolean(form.storePassword)} + onChange={(e) => setForm({ ...form, storePassword: e.target.checked })} sx={{ color: theme.palette.text.primary, '&.Mui-checked': { @@ -253,119 +385,32 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd }} /> - {form.rememberHost && ( - <> - - Store Password - setForm({ ...form, storePassword: e.target.checked })} - sx={{ - color: theme.palette.text.primary, - '&.Mui-checked': { - color: theme.palette.text.primary, - }, - }} - /> - - - Authentication Method - - + )} +
+
+
- {form.authMethod === 'password' && ( - - Password -
- setForm({ ...form, password: e.target.value })} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - flex: 1 - }} - /> - setShowPassword(!showPassword)} - sx={{ - color: theme.palette.text.primary, - marginLeft: 1 - }} - > - {showPassword ? : } - -
-
- )} - - {form.authMethod === 'rsaKey' && ( - - Public Key - - - )} - - )} -
-
-
- - - - + + @@ -374,18 +419,7 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd 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, + form: PropTypes.object.isRequired, setForm: PropTypes.func.isRequired, handleAddHost: PropTypes.func.isRequired, setIsAddHostHidden: PropTypes.func.isRequired, diff --git a/src/modals/AuthModal.jsx b/src/modals/AuthModal.jsx new file mode 100644 index 00000000..93cb46ae --- /dev/null +++ b/src/modals/AuthModal.jsx @@ -0,0 +1,306 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { + Modal, + Button, + FormControl, + FormLabel, + Input, + Stack, + DialogContent, + ModalDialog, + IconButton, + Tabs, + TabList, + Tab, + TabPanel +} 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'; +import eventBus from '/src/other/eventBus'; + +const AuthModal = ({ + isHidden, + form, + setForm, + handleLoginUser, + handleCreateUser, + handleGuestLogin, + setIsAuthModalHidden + }) => { + const [activeTab, setActiveTab] = useState(0); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const loginErrorHandler = () => setIsLoading(false); + eventBus.on('failedLoginUser', loginErrorHandler); + return () => eventBus.off('failedLoginUser', loginErrorHandler); + }, []); + + const resetForm = () => { + setForm({ username: '', password: '' }); + setShowPassword(false); + setShowConfirmPassword(false); + setIsLoading(false); + }; + + const handleLogin = async () => { + setIsLoading(true); + try { + await handleLoginUser({ + ...form, + onSuccess: () => { + setIsLoading(false); + setIsAuthModalHidden(true); + }, + onFailure: () => setIsLoading(false), + }); + } catch (error) { + setIsLoading(false); + } + }; + + const handleCreate = async () => { + setIsLoading(true); + try { + await handleCreateUser({ + ...form, + onSuccess: () => { + setIsLoading(false); + setActiveTab(0); + setIsAuthModalHidden(true); + }, + onFailure: () => setIsLoading(false), + }); + } catch (error) { + setIsLoading(false); + } + }; + + const handleGuest = async () => { + setIsLoading(true); + try { + await handleGuestLogin({ + onSuccess: () => { + setIsLoading(false); + setIsAuthModalHidden(true); + }, + onFailure: () => setIsLoading(false) + }); + } catch (error) { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isHidden) resetForm(); + }, [isHidden]); + + const isLoginValid = !!form.username && !!form.password; + const isCreateValid = isLoginValid && form.password === form.confirmPassword; + + return ( + + setIsAuthModalHidden(true)}> + + setActiveTab(val)} + sx={{ + width: '100%', + backgroundColor: theme.palette.general.tertiary, + }} + > + + Login + Create + + + + + { e.preventDefault(); handleLogin(); }}> + + Username + setForm({ ...form, username: e.target.value })} + sx={inputStyle} + /> + + + Password +
+ setForm({ ...form, password: e.target.value })} + sx={{ ...inputStyle, flex: 1 }} + /> + setShowPassword(!showPassword)} + sx={iconButtonStyle} + > + {showPassword ? : } + +
+
+ + +
+
+ + + { e.preventDefault(); handleCreate(); }}> + + Username + setForm({ ...form, username: e.target.value })} + sx={inputStyle} + /> + + + Password +
+ setForm({ ...form, password: e.target.value })} + sx={{ ...inputStyle, flex: 1 }} + /> + setShowPassword(!showPassword)} + sx={iconButtonStyle} + > + {showPassword ? : } + +
+
+ + Confirm Password +
+ setForm({ ...form, confirmPassword: e.target.value })} + sx={{ ...inputStyle, flex: 1 }} + /> + setShowConfirmPassword(!showConfirmPassword)} + sx={iconButtonStyle} + > + {showConfirmPassword ? : } + +
+
+ +
+
+
+
+
+
+
+ ); +}; + +const inputStyle = { + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + '&:disabled': { + opacity: 0.5, + backgroundColor: theme.palette.general.primary, + }, +}; + +const iconButtonStyle = { + color: theme.palette.text.primary, + marginLeft: 1, + '&:disabled': { opacity: 0.5 }, +}; + +const buttonStyle = { + backgroundColor: theme.palette.general.primary, + '&:hover': { backgroundColor: theme.palette.general.disabled }, + '&:disabled': { + opacity: 0.5, + backgroundColor: theme.palette.general.primary, + }, +}; + +AuthModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + form: PropTypes.object.isRequired, + setForm: PropTypes.func.isRequired, + handleLoginUser: PropTypes.func.isRequired, + handleCreateUser: PropTypes.func.isRequired, + handleGuestLogin: PropTypes.func.isRequired, + setIsAuthModalHidden: PropTypes.func.isRequired, +}; + +export default AuthModal; \ No newline at end of file diff --git a/src/modals/CreateUserModal.jsx b/src/modals/CreateUserModal.jsx deleted file mode 100644 index c48a18cd..00000000 --- a/src/modals/CreateUserModal.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import PropTypes from 'prop-types'; -import { CssVarsProvider } from '@mui/joy/styles'; -import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog, IconButton } from '@mui/joy'; -import theme from '/src/theme'; -import { useEffect, useState } from 'react'; -import Visibility from '@mui/icons-material/Visibility'; -import VisibilityOff from '@mui/icons-material/VisibilityOff'; - -const CreateUserModal = ({ isHidden, form, setForm, handleCreateUser, setIsCreateUserHidden, setIsLoginUserHidden }) => { - const [confirmPassword, setConfirmPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - - const isFormValid = () => { - if (!form.username || !form.password || form.password !== confirmPassword) return false; - return true; - }; - - const handleCreate = () => { - handleCreateUser({ - ...form - }); - }; - - useEffect(() => { - if (isHidden) { - setForm({ username: '', password: '' }); - setConfirmPassword(''); - } - }, [isHidden]); - - return ( - - {}}> - - Create - -
{ - event.preventDefault(); - if (isFormValid()) handleCreate(); - }} - > - - - Username - setForm({ ...form, username: event.target.value })} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - Password -
- setForm({ ...form, password: event.target.value })} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - flex: 1, - }} - /> - setShowPassword(!showPassword)} - sx={{ - color: theme.palette.text.primary, - marginLeft: 1, - }} - > - {showPassword ? : } - -
-
- - Confirm Password -
- setConfirmPassword(event.target.value)} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - flex: 1, - }} - /> - setShowConfirmPassword(!showConfirmPassword)} - sx={{ - color: theme.palette.text.primary, - marginLeft: 1, - }} - > - {showConfirmPassword ? : } - -
-
- - -
-
-
-
-
-
- ); -}; - -CreateUserModal.propTypes = { - isHidden: PropTypes.bool.isRequired, - form: PropTypes.object.isRequired, - setForm: PropTypes.func.isRequired, - handleCreateUser: PropTypes.func.isRequired, - setIsCreateUserHidden: PropTypes.func.isRequired, - setIsLoginUserHidden: PropTypes.func.isRequired, -}; - -export default CreateUserModal; \ No newline at end of file diff --git a/src/modals/EditHostModal.jsx b/src/modals/EditHostModal.jsx index 048fb12d..02aafb40 100644 --- a/src/modals/EditHostModal.jsx +++ b/src/modals/EditHostModal.jsx @@ -8,8 +8,6 @@ import { FormLabel, Input, Stack, - DialogTitle, - DialogContent, ModalDialog, Select, Option, @@ -24,10 +22,25 @@ 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 EditHostModal = ({ isHidden, hostConfig, setIsEditHostHidden, handleEditHost }) => { + const [form, setForm] = useState({ + name: '', + folder: '', + ip: '', + user: '', + port: '', + password: '', + sshKey: '', + keyType: '', + authMethod: 'Select Auth', + storePassword: true, + rememberHost: true + }); const [showPassword, setShowPassword] = useState(false); const [activeTab, setActiveTab] = useState(0); const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [showError, setShowError] = useState(false); useEffect(() => { if (!isHidden && hostConfig) { @@ -37,54 +50,84 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH ip: hostConfig.ip || '', user: hostConfig.user || '', password: hostConfig.password || '', - rsaKey: hostConfig.rsaKey || '', + sshKey: hostConfig.sshKey || '', + keyType: hostConfig.keyType || '', port: hostConfig.port || 22, - authMethod: hostConfig.password ? 'password' : hostConfig.rsaKey ? 'rsaKey' : 'Select Auth', + authMethod: hostConfig.password ? 'password' : hostConfig.sshKey ? 'key' : 'Select Auth', rememberHost: true, - storePassword: !!(hostConfig.password || hostConfig.rsaKey), + storePassword: !!(hostConfig.password || hostConfig.sshKey), }); } }, [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 supportedKeyTypes = { + 'id_rsa': 'RSA', + 'id_ed25519': 'ED25519', + 'id_ecdsa': 'ECDSA', + 'id_dsa': 'DSA', + '.pem': 'PEM', + '.key': 'KEY', + '.ppk': 'PPK' + }; + + const isValidKeyFile = Object.keys(supportedKeyTypes).some(ext => + file.name.toLowerCase().includes(ext) || file.name.endsWith('.pub') + ); + + if (isValidKeyFile) { const reader = new FileReader(); reader.onload = (evt) => { - setForm((prev) => ({ ...prev, rsaKey: evt.target.result })); + const keyContent = evt.target.result; + let keyType = 'UNKNOWN'; + + if (keyContent.includes('BEGIN RSA PRIVATE KEY') || keyContent.includes('BEGIN RSA PUBLIC KEY')) { + keyType = 'RSA'; + } else if (keyContent.includes('BEGIN OPENSSH PRIVATE KEY') && keyContent.includes('ssh-ed25519')) { + keyType = 'ED25519'; + } else if (keyContent.includes('BEGIN EC PRIVATE KEY') || keyContent.includes('BEGIN EC PUBLIC KEY')) { + keyType = 'ECDSA'; + } else if (keyContent.includes('BEGIN DSA PRIVATE KEY')) { + keyType = 'DSA'; + } + + setForm((prev) => ({ + ...prev, + sshKey: keyContent, + keyType: keyType, + authMethod: 'key' + })); }; reader.readAsText(file); } else { - alert('Please upload a valid RSA private key file.'); + alert('Please upload a valid SSH key file (RSA, ED25519, ECDSA, DSA, PEM, or PPK format).'); } }; 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" + authMethod: newMethod, + password: "", + sshKey: "", + keyType: "", })); }; const isFormValid = () => { - const { ip, user, port, authMethod, password, rsaKey, storePassword } = form; + const { ip, user, port, authMethod, password, sshKey } = 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; + if (form.storePassword) { + if (authMethod === 'Select Auth') return false; + if (authMethod === 'password' && !password?.trim()) return false; + if (authMethod === 'key' && !sshKey?.trim()) return false; + } return true; }; @@ -92,18 +135,49 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH const handleSubmit = async (event) => { event.preventDefault(); if (isLoading) return; - + setIsLoading(true); try { - await handleEditHost(hostConfig, { + setErrorMessage(""); + setShowError(false); + + if (!form.ip || !form.user) { + setErrorMessage("IP and Username are required fields"); + setShowError(true); + setIsLoading(false); + return; + } + + if (!form.port) { + setErrorMessage("Port is required"); + setShowError(true); + setIsLoading(false); + return; + } + + const newConfig = { 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), - }); + }; + + if (form.storePassword) { + if (form.authMethod === 'password') { + newConfig.password = form.password; + } else if (form.authMethod === 'key') { + newConfig.sshKey = form.sshKey; + newConfig.keyType = form.keyType; + } + } + + await handleEditHost(hostConfig, newConfig); + setActiveTab(0); + } catch (error) { + console.error("Edit host error:", error); + setErrorMessage(error.message || "Failed to edit host. The host name may already exist."); + setShowError(true); } finally { setIsLoading(false); } @@ -115,11 +189,9 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH open={!isHidden} onClose={() => !isLoading && setIsEditHostHidden(true)} sx={{ - position: 'fixed', - inset: 0, display: 'flex', - alignItems: 'center', justifyContent: 'center', + alignItems: 'center', backdropFilter: 'blur(5px)', backgroundColor: 'rgba(0, 0, 0, 0.2)', }} @@ -131,7 +203,7 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH backgroundColor: theme.palette.general.tertiary, borderColor: theme.palette.general.secondary, color: theme.palette.text.primary, - padding: 3, + padding: 0, borderRadius: 10, maxWidth: '500px', width: '100%', @@ -141,134 +213,133 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH mx: 2, }} > - Edit Host - -
- setActiveTab(val)} - sx={{ - backgroundColor: theme.palette.general.disabled, - borderRadius: '8px', - padding: '8px', - marginBottom: '16px', - width: '100%', - }} - > - + {errorMessage} + + )} + setActiveTab(val)} + sx={{ + width: '100%', + mb: 0, + backgroundColor: theme.palette.general.tertiary, + }} + > + - Basic Info - Connection - Authentication - + }, + }, + }} + > + Basic Info + Connection + Authentication + - - - - Host Name - setForm((prev) => ({ ...prev, name: e.target.value }))} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary - }} - /> - +
+ + + + Host Name + setForm({ ...form, name: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Folder + setForm({ ...form, folder: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + - - Folder - setForm((prev) => ({ ...prev, folder: e.target.value }))} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary - }} - /> - - - + + + + Host IP + setForm({ ...form, ip: e.target.value })} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Host User + setForm({ ...form, user: e.target.value })} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + 65535}> + Host Port + setForm({ ...form, port: e.target.value })} + min={1} + max={65535} + required + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + - - - - Host IP - setForm((prev) => ({ ...prev, ip: e.target.value }))} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary - }} - /> - - - 65535}> - Host Port - setForm((prev) => ({ ...prev, port: e.target.value }))} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary - }} - /> - - - - Host User - setForm((prev) => ({ ...prev, user: e.target.value }))} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary - }} - /> - - - - - - - - Store Password - handleStorePasswordChange(e.target.checked)} - sx={{ - color: theme.palette.text.primary, - '&.Mui-checked': { - color: theme.palette.text.primary - } - }} - /> - - - {form.storePassword && ( - + + + {form.storePassword && ( + <> + Authentication Method - )} - {form.authMethod === 'password' && form.storePassword && ( - - Password -
- setForm((prev) => ({ ...prev, password: e.target.value }))} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - flex: 1 - }} - /> - setShowPassword(!showPassword)} - sx={{ - color: theme.palette.text.primary, - marginLeft: 1 - }} - > - {showPassword ? : } - -
-
- )} + {form.authMethod === 'password' && ( + + Password +
+ setForm({ ...form, password: e.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + flex: 1 + }} + /> + setShowPassword(!showPassword)} + sx={{ + color: theme.palette.text.primary, + marginLeft: 1 + }} + > + {showPassword ? : } + +
+
+ )} - {form.authMethod === 'rsaKey' && form.storePassword && ( - - Public Key - - {hostConfig?.rsaKey && !form.rsaKey && ( - - Existing key detected. Upload to replace. - - )} - - )} -
-
- + {form.authMethod === 'key' && ( + + + SSH Key + + {hostConfig?.sshKey && !form.sshKey && ( + + Existing {hostConfig.keyType || 'SSH'} key detected. Upload to replace. + + )} + + + )} + + )} - - - + + Store Password + setForm({ + ...form, + storePassword: e.target.checked, + password: e.target.checked ? form.password : "", + sshKey: e.target.checked ? form.sshKey : "", + authMethod: e.target.checked ? form.authMethod : "Select Auth" + })} + sx={{ + color: theme.palette.text.primary, + '&.Mui-checked': { + color: theme.palette.text.primary, + }, + }} + /> + +
+
+
+ + +
@@ -386,11 +479,9 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH EditHostModal.propTypes = { isHidden: PropTypes.bool.isRequired, - form: PropTypes.object.isRequired, - setForm: PropTypes.func.isRequired, - handleEditHost: PropTypes.func.isRequired, + hostConfig: PropTypes.object, setIsEditHostHidden: PropTypes.func.isRequired, - hostConfig: PropTypes.object + handleEditHost: PropTypes.func.isRequired }; export default EditHostModal; \ No newline at end of file diff --git a/src/modals/LoginUserModal.jsx b/src/modals/LoginUserModal.jsx deleted file mode 100644 index efbe740f..00000000 --- a/src/modals/LoginUserModal.jsx +++ /dev/null @@ -1,150 +0,0 @@ -import PropTypes from 'prop-types'; -import { CssVarsProvider } from '@mui/joy/styles'; -import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog, IconButton } from '@mui/joy'; -import theme from '/src/theme'; -import { useEffect, useState } from 'react'; -import Visibility from '@mui/icons-material/Visibility'; -import VisibilityOff from '@mui/icons-material/VisibilityOff'; - -const LoginUserModal = ({ isHidden, form, setForm, handleLoginUser, handleGuestLogin, setIsLoginUserHidden, setIsCreateUserHidden }) => { - const [showPassword, setShowPassword] = useState(false); - - const isFormValid = () => { - if (!form.username || !form.password) return false; - return true; - }; - - const handleLogin = () => { - handleLoginUser({ - ...form, - }); - }; - - useEffect(() => { - if (isHidden) { - setForm({ username: '', password: '' }); - } - }, [isHidden]); - - return ( - - {}}> - - Login - -
{ - event.preventDefault(); - if (isFormValid()) handleLogin(); - }} - > - - - Username - setForm({ ...form, username: event.target.value })} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - }} - /> - - - Password -
- setForm({ ...form, password: event.target.value })} - sx={{ - backgroundColor: theme.palette.general.primary, - color: theme.palette.text.primary, - flex: 1, - }} - /> - setShowPassword(!showPassword)} - sx={{ - color: theme.palette.text.primary, - marginLeft: 1, - }} - > - {showPassword ? : } - -
-
- - - -
-
-
-
-
-
- ); -}; - -LoginUserModal.propTypes = { - isHidden: PropTypes.bool.isRequired, - form: PropTypes.object.isRequired, - setForm: PropTypes.func.isRequired, - handleLoginUser: PropTypes.func.isRequired, - handleGuestLogin: PropTypes.func.isRequired, - setIsLoginUserHidden: PropTypes.func.isRequired, - setIsCreateUserHidden: PropTypes.func.isRequired, -}; - -export default LoginUserModal; \ No newline at end of file diff --git a/src/modals/NoAuthenticationModal.jsx b/src/modals/NoAuthenticationModal.jsx index 6829aaf0..c0c164a0 100644 --- a/src/modals/NoAuthenticationModal.jsx +++ b/src/modals/NoAuthenticationModal.jsx @@ -15,7 +15,7 @@ import { Option, } from '@mui/joy'; import theme from '/src/theme'; -import { useState, useEffect } from 'react'; +import {useEffect, useState} from 'react'; import Visibility from '@mui/icons-material/Visibility'; import VisibilityOff from '@mui/icons-material/VisibilityOff'; @@ -26,23 +26,91 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han if (!form.authMethod) { setForm(prev => ({ ...prev, - authMethod: 'Select Auth' + authMethod: 'Select Auth', + password: '', + sshKey: '', + keyType: '', })); } }, []); const isFormValid = () => { if (!form.authMethod || form.authMethod === 'Select Auth') return false; - if (form.authMethod === 'rsaKey' && !form.rsaKey) return false; + if (form.authMethod === 'sshKey' && !form.sshKey) 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: '' }); + const handleSubmit = (e) => { + e.preventDefault(); + e.stopPropagation(); + + try { + if(isFormValid()) { + const formData = { + authMethod: form.authMethod, + password: form.authMethod === 'password' ? form.password : '', + sshKey: form.authMethod === 'sshKey' ? form.sshKey : '', + keyType: form.authMethod === 'sshKey' ? form.keyType : '', + }; + + handleAuthSubmit(formData); + + setForm(prev => ({ + ...prev, + authMethod: 'Select Auth', + password: '', + sshKey: '', + keyType: '', + })); + } + } catch (error) { + console.error("Authentication form error:", error); + } + }; + + const handleFileChange = (e) => { + const file = e.target.files[0]; + const supportedKeyTypes = { + 'id_rsa': 'RSA', + 'id_ed25519': 'ED25519', + 'id_ecdsa': 'ECDSA', + 'id_dsa': 'DSA', + '.pem': 'PEM', + '.key': 'KEY', + '.ppk': 'PPK' + }; + + const isValidKeyFile = Object.keys(supportedKeyTypes).some(ext => + file.name.toLowerCase().includes(ext) || file.name.endsWith('.pub') + ); + + if (isValidKeyFile) { + const reader = new FileReader(); + reader.onload = (event) => { + const keyContent = event.target.result; + let keyType = 'UNKNOWN'; + + if (keyContent.includes('BEGIN RSA PRIVATE KEY') || keyContent.includes('BEGIN RSA PUBLIC KEY')) { + keyType = 'RSA'; + } else if (keyContent.includes('BEGIN OPENSSH PRIVATE KEY') && keyContent.includes('ssh-ed25519')) { + keyType = 'ED25519'; + } else if (keyContent.includes('BEGIN EC PRIVATE KEY') || keyContent.includes('BEGIN EC PUBLIC KEY')) { + keyType = 'ECDSA'; + } else if (keyContent.includes('BEGIN DSA PRIVATE KEY')) { + keyType = 'DSA'; + } + + setForm({ + ...form, + sshKey: keyContent, + keyType: keyType, + authMethod: 'sshKey' + }); + }; + reader.readAsText(file); + } else { + alert('Please upload a valid SSH key file (RSA, ED25519, ECDSA, DSA, PEM, or PPK format).'); } }; @@ -85,7 +153,13 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han Authentication Method @@ -102,9 +176,9 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han Password
setForm(prev => ({ ...prev, password: e.target.value }))} + onChange={(e) => setForm({...form, password: e.target.value})} sx={{ backgroundColor: theme.palette.general.primary, color: theme.palette.text.primary, @@ -115,7 +189,10 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han onClick={() => setShowPassword(!showPassword)} sx={{ color: theme.palette.text.primary, - marginLeft: 1 + marginLeft: 1, + '&:disabled': { + opacity: 0.5, + }, }} > {showPassword ? : } @@ -124,41 +201,34 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han )} - {form.authMethod === 'rsaKey' && ( - - Public Key - - + > + {form.sshKey ? `Change ${form.keyType || 'SSH'} Key File` : 'Upload SSH Key File'} + + + + )} + +
+ v0.2.1 +
diff --git a/src/other/eventBus.jsx b/src/other/eventBus.jsx new file mode 100644 index 00000000..ac7a358c --- /dev/null +++ b/src/other/eventBus.jsx @@ -0,0 +1,5 @@ +import mitt from "mitt"; + +const eventBus = mitt(); + +export default eventBus; \ No newline at end of file