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
This commit was merged in pull request #30.
This commit is contained in:
Karmaa
2025-03-23 22:17:56 -05:00
committed by GitHub
parent 10bc491a9f
commit 6940f4e9bb
20 changed files with 1962 additions and 1351 deletions

View File

@@ -4,6 +4,9 @@ on:
push: push:
branches: branches:
- development - development
paths-ignore:
- '**.md'
- '.gitignore'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag_name: tag_name:
@@ -16,27 +19,44 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v2
with: 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 - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
driver-opts: |
image=moby/buildkit:master
network=host
- 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 - name: Login to Docker Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -53,21 +73,36 @@ jobs:
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
- name: Build and Push Multi-Arch Docker Image - name: Build and Push Multi-Arch Docker Image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./docker/Dockerfile file: ./docker/Dockerfile
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ env.IMAGE_TAG }} 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 - name: Notify via ntfy
if: success()
run: | run: |
curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \ curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \
https://ntfy.karmaa.site/termix-build https://ntfy.karmaa.site/termix-build
- name: Delete all untagged image versions - name: Delete all untagged image versions
if: success()
uses: quartx-analytics/ghcr-cleaner@v1 uses: quartx-analytics/ghcr-cleaner@v1
with: with:
owner-type: user owner-type: user
@@ -76,6 +111,7 @@ jobs:
delete-untagged: true delete-untagged: true
- name: Cleanup Docker Images Locally - name: Cleanup Docker Images Locally
if: always()
run: | run: |
docker image prune -af docker image prune -af
docker system prune -af --volumes docker system prune -af --volumes

View File

@@ -1,61 +1,107 @@
# Stage 1: Build frontend # Stage 1: Build frontend
FROM --platform=$BUILDPLATFORM node:18 AS frontend-builder FROM node:18-alpine AS frontend-builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci --force && \
npm cache clean --force
COPY . . COPY . .
RUN npm run build RUN npm run build
# Stage 2: Build backend # Stage 2: Build backend
FROM --platform=$BUILDPLATFORM node:18 AS backend-builder FROM node:18-alpine AS backend-builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci --only=production --force && \
npm cache clean --force
COPY src/backend/ ./src/backend/ COPY src/backend/ ./src/backend/
# Stage 3: Final production image # Stage 3: Build bcrypt for Ubuntu
FROM mongo:5 FROM ubuntu:focal AS bcrypt-builder
# Install Node.js ENV DEBIAN_FRONTEND=noninteractive \
RUN apt-get update && apt-get install -y \ NODE_VERSION=18.x
curl \ WORKDIR /app
nginx \ 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 \ python3 \
build-essential \ make \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ g++ \
&& apt-get install -y nodejs \ wget \
&& apt-get clean \ ca-certificates \
&& rm -rf /var/lib/apt/lists/* 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 docker/nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend-builder /app/dist /usr/share/nginx/html COPY --from=frontend-builder /app/dist /usr/share/nginx/html
RUN chown -R www-data:www-data /usr/share/nginx/html
# Setup backend # Setup backend
WORKDIR /app WORKDIR /app
COPY package*.json ./ 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 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"] VOLUME ["/data/db"]
# Expose ports
EXPOSE 8080 8081 8082 27017 EXPOSE 8080 8081 8082 27017
# Use a entrypoint script to run all services
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]

View File

@@ -1,32 +1,124 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Start MongoDB # Create required directories and set permissions
echo "Starting MongoDB..." mkdir -p /data/db /var/log/mongodb /var/run/mongodb
mongod --fork --dbpath $MONGODB_DATA_DIR --logpath $MONGODB_LOG_DIR/mongodb.log 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 # Wait for MongoDB to be ready
echo "Waiting for MongoDB to start..." echo "Waiting for MongoDB to start..."
until mongosh --eval "print(\"waited for connection\")" > /dev/null 2>&1; do MAX_TRIES=30
sleep 0.5 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 done
echo "MongoDB has started" echo "MongoDB started successfully"
# Start nginx # Start nginx
echo "Starting nginx..." echo "Starting nginx..."
nginx nginx
# Change to app directory # Start backend services
echo "Starting backend services..."
cd /app cd /app
export NODE_ENV=production
# Start the SSH service # Start SSH service
echo "Starting SSH service..." su -s /bin/bash node -c "node src/backend/ssh.cjs" &
node src/backend/ssh.cjs &
# Start the database service # Start database service
echo "Starting database service..." su -s /bin/bash node -c "node src/backend/database.cjs" &
node src/backend/database.cjs &
# Keep the container running and show MongoDB logs echo "All services started"
echo "All services started. Tailing MongoDB logs..."
# Keep container running and show logs
tail -f $MONGODB_LOG_DIR/mongodb.log tail -f $MONGODB_LOG_DIR/mongodb.log

241
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@fontsource/inter": "^5.1.1", "@fontsource/inter": "^5.1.1",
"@mui/icons-material": "^6.4.7", "@mui/icons-material": "^6.4.7",
"@mui/joy": "^5.0.0-beta.51", "@mui/joy": "^5.0.0-beta.51",
"@tailwindcss/vite": "^4.0.8", "@tailwindcss/vite": "^4.0.15",
"@tiptap/extension-link": "^2.11.5", "@tiptap/extension-link": "^2.11.5",
"@tiptap/pm": "^2.11.5", "@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5", "@tiptap/react": "^2.11.5",
@@ -29,6 +29,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"is-stream": "^4.0.1", "is-stream": "^4.0.1",
"make-dir": "^5.0.0", "make-dir": "^5.0.0",
"mitt": "^3.0.1",
"mongoose": "^8.12.1", "mongoose": "^8.12.1",
"node-ssh": "^13.2.0", "node-ssh": "^13.2.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
@@ -41,7 +42,7 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"tailwindcss": "^4.0.8" "tailwindcss": "^4.0.15"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
@@ -1225,15 +1226,6 @@
"node-pre-gyp": "bin/node-pre-gyp" "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": { "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -2011,42 +2003,42 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.15.tgz",
"integrity": "sha512-FKArQpbrbwv08TNT0k7ejYXpF+R8knZFAatNc0acOxbgeqLzwb86r+P3LGOjIeI3Idqe9CVkZrh4GlsJLJKkkw==", "integrity": "sha512-IODaJjNmiasfZX3IoS+4Em3iu0fD2HS0/tgrnkYfW4hyUor01Smnr5eY3jc4rRgaTDrJlDmBTHbFO0ETTDaxWA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"enhanced-resolve": "^5.18.1", "enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2", "jiti": "^2.4.2",
"tailwindcss": "4.0.8" "tailwindcss": "4.0.15"
} }
}, },
"node_modules/@tailwindcss/oxide": { "node_modules/@tailwindcss/oxide": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.15.tgz",
"integrity": "sha512-KfMcuAu/Iw+DcV1e8twrFyr2yN8/ZDC/odIGta4wuuJOGkrkHZbvJvRNIbQNhGh7erZTYV6Ie0IeD6WC9Y8Hcw==", "integrity": "sha512-e0uHrKfPu7JJGMfjwVNyt5M0u+OP8kUmhACwIRlM+JNBuReDVQ63yAD1NWe5DwJtdaHjugNBil76j+ks3zlk6g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.0.8", "@tailwindcss/oxide-android-arm64": "4.0.15",
"@tailwindcss/oxide-darwin-arm64": "4.0.8", "@tailwindcss/oxide-darwin-arm64": "4.0.15",
"@tailwindcss/oxide-darwin-x64": "4.0.8", "@tailwindcss/oxide-darwin-x64": "4.0.15",
"@tailwindcss/oxide-freebsd-x64": "4.0.8", "@tailwindcss/oxide-freebsd-x64": "4.0.15",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.8", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.15",
"@tailwindcss/oxide-linux-arm64-gnu": "4.0.8", "@tailwindcss/oxide-linux-arm64-gnu": "4.0.15",
"@tailwindcss/oxide-linux-arm64-musl": "4.0.8", "@tailwindcss/oxide-linux-arm64-musl": "4.0.15",
"@tailwindcss/oxide-linux-x64-gnu": "4.0.8", "@tailwindcss/oxide-linux-x64-gnu": "4.0.15",
"@tailwindcss/oxide-linux-x64-musl": "4.0.8", "@tailwindcss/oxide-linux-x64-musl": "4.0.15",
"@tailwindcss/oxide-win32-arm64-msvc": "4.0.8", "@tailwindcss/oxide-win32-arm64-msvc": "4.0.15",
"@tailwindcss/oxide-win32-x64-msvc": "4.0.8" "@tailwindcss/oxide-win32-x64-msvc": "4.0.15"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": { "node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.15.tgz",
"integrity": "sha512-We7K79+Sm4mwJHk26Yzu/GAj7C7myemm7PeXvpgMxyxO70SSFSL3uCcqFbz9JA5M5UPkrl7N9fkBe/Y0iazqpA==", "integrity": "sha512-EBuyfSKkom7N+CB3A+7c0m4+qzKuiN0WCvzPvj5ZoRu4NlQadg/mthc1tl5k9b5ffRGsbDvP4k21azU4VwVk3Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2060,9 +2052,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-arm64": { "node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.15.tgz",
"integrity": "sha512-Lv9Isi2EwkCTG1sRHNDi0uRNN1UGFdEThUAGFrydRmQZnraGLMjN8gahzg2FFnOizDl7LB2TykLUuiw833DSNg==", "integrity": "sha512-ObVAnEpLepMhV9VoO0JSit66jiN5C4YCqW3TflsE9boo2Z7FIjV80RFbgeL2opBhtxbaNEDa6D0/hq/EP03kgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2076,9 +2068,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": { "node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.15.tgz",
"integrity": "sha512-fWfywfYIlSWtKoqWTjukTHLWV3ARaBRjXCC2Eo0l6KVpaqGY4c2y8snUjp1xpxUtpqwMvCvFWFaleMoz1Vhzlw==", "integrity": "sha512-IElwoFhUinOr9MyKmGTPNi1Rwdh68JReFgYWibPWTGuevkHkLWKEflZc2jtI5lWZ5U9JjUnUfnY43I4fEXrc4g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2092,9 +2084,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-freebsd-x64": { "node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.15.tgz",
"integrity": "sha512-SO+dyvjJV9G94bnmq2288Ke0BIdvrbSbvtPLaQdqjqHR83v5L2fWADyFO+1oecHo9Owsk8MxcXh1agGVPIKIqw==", "integrity": "sha512-6BLLqyx7SIYRBOnTZ8wgfXANLJV5TQd3PevRJZp0vn42eO58A2LykRKdvL1qyPfdpmEVtF+uVOEZ4QTMqDRAWA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2108,9 +2100,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.15.tgz",
"integrity": "sha512-ZSHggWiEblQNV69V0qUK5vuAtHP+I+S2eGrKGJ5lPgwgJeAd6GjLsVBN+Mqn2SPVfYM3BOpS9jX/zVg9RWQVDQ==", "integrity": "sha512-Zy63EVqO9241Pfg6G0IlRIWyY5vNcWrL5dd2WAKVJZRQVeolXEf1KfjkyeAAlErDj72cnyXObEZjMoPEKHpdNw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2124,9 +2116,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.15.tgz",
"integrity": "sha512-xWpr6M0OZLDNsr7+bQz+3X7zcnDJZJ1N9gtBWCtfhkEtDjjxYEp+Lr5L5nc/yXlL4MyCHnn0uonGVXy3fhxaVA==", "integrity": "sha512-2NemGQeaTbtIp1Z2wyerbVEJZTkAWhMDOhhR5z/zJ75yMNf8yLnE+sAlyf6yGDNr+1RqvWrRhhCFt7i0CIxe4Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2140,9 +2132,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-musl": { "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.15.tgz",
"integrity": "sha512-5tz2IL7LN58ssGEq7h/staD7pu/izF/KeMWdlJ86WDe2Ah46LF3ET6ZGKTr5eZMrnEA0M9cVFuSPprKRHNgjeg==", "integrity": "sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2156,9 +2148,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.15.tgz",
"integrity": "sha512-KSzMkhyrxAQyY2o194NKVKU9j/c+NFSoMvnHWFaNHKi3P1lb+Vq1UC19tLHrmxSkKapcMMu69D7+G1+FVGNDXQ==", "integrity": "sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2172,9 +2164,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-musl": { "node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.15.tgz",
"integrity": "sha512-yFYKG5UtHTRimjtqxUWXBgI4Tc6NJe3USjRIVdlTczpLRxq/SFwgzGl5JbatCxgSRDPBFwRrNPxq+ukfQFGdrw==", "integrity": "sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2188,9 +2180,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.15.tgz",
"integrity": "sha512-tndGujmCSba85cRCnQzXgpA2jx5gXimyspsUYae5jlPyLRG0RjXbDshFKOheVXU4TLflo7FSG8EHCBJ0EHTKdQ==", "integrity": "sha512-7QtSSJwYZ7ZK1phVgcNZpuf7c7gaCj8Wb0xjliligT5qCGCp79OV2n3SJummVZdw4fbTNKUOYMO7m1GinppZyA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2204,9 +2196,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.15.tgz",
"integrity": "sha512-T77jroAc0p4EHVVgTUiNeFn6Nj3jtD3IeNId2X+0k+N1XxfNipy81BEkYErpKLiOkNhpNFjPee8/ZVas29b2OQ==", "integrity": "sha512-JQ5H+5MLhOjpgNp6KomouE0ZuKmk3hO5h7/ClMNAQ8gZI2zkli3IH8ZqLbd2DVfXDbdxN2xvooIEeIlkIoSCqw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2220,15 +2212,15 @@
} }
}, },
"node_modules/@tailwindcss/vite": { "node_modules/@tailwindcss/vite": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.8.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.15.tgz",
"integrity": "sha512-+SAq44yLzYlzyrb7QTcFCdU8Xa7FOA0jp+Xby7fPMUie+MY9HhJysM7Vp+vL8qIp8ceQJfLD+FjgJuJ4lL6nyg==", "integrity": "sha512-JRexava80NijI8cTcLXNM3nQL5A0ptTHI8oJLLe8z1MpNB6p5J4WCdJJP8RoyHu8/eB1JzEdbpH86eGfbuaezQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tailwindcss/node": "4.0.8", "@tailwindcss/node": "4.0.15",
"@tailwindcss/oxide": "4.0.8", "@tailwindcss/oxide": "4.0.15",
"lightningcss": "^1.29.1", "lightningcss": "1.29.2",
"tailwindcss": "4.0.8" "tailwindcss": "4.0.15"
}, },
"peerDependencies": { "peerDependencies": {
"vite": "^5.2.0 || ^6" "vite": "^5.2.0 || ^6"
@@ -3932,15 +3924,12 @@
} }
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "1.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": { "engines": {
"node": ">=0.10" "node": ">=8"
} }
}, },
"node_modules/doctrine": { "node_modules/doctrine": {
@@ -5880,12 +5869,12 @@
} }
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
"integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^1.0.3" "detect-libc": "^2.0.3"
}, },
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
@@ -5895,22 +5884,22 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-darwin-arm64": "1.29.1", "lightningcss-darwin-arm64": "1.29.2",
"lightningcss-darwin-x64": "1.29.1", "lightningcss-darwin-x64": "1.29.2",
"lightningcss-freebsd-x64": "1.29.1", "lightningcss-freebsd-x64": "1.29.2",
"lightningcss-linux-arm-gnueabihf": "1.29.1", "lightningcss-linux-arm-gnueabihf": "1.29.2",
"lightningcss-linux-arm64-gnu": "1.29.1", "lightningcss-linux-arm64-gnu": "1.29.2",
"lightningcss-linux-arm64-musl": "1.29.1", "lightningcss-linux-arm64-musl": "1.29.2",
"lightningcss-linux-x64-gnu": "1.29.1", "lightningcss-linux-x64-gnu": "1.29.2",
"lightningcss-linux-x64-musl": "1.29.1", "lightningcss-linux-x64-musl": "1.29.2",
"lightningcss-win32-arm64-msvc": "1.29.1", "lightningcss-win32-arm64-msvc": "1.29.2",
"lightningcss-win32-x64-msvc": "1.29.1" "lightningcss-win32-x64-msvc": "1.29.2"
} }
}, },
"node_modules/lightningcss-darwin-arm64": { "node_modules/lightningcss-darwin-arm64": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
"integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -5928,9 +5917,9 @@
} }
}, },
"node_modules/lightningcss-darwin-x64": { "node_modules/lightningcss-darwin-x64": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
"integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -5948,9 +5937,9 @@
} }
}, },
"node_modules/lightningcss-freebsd-x64": { "node_modules/lightningcss-freebsd-x64": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
"integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -5968,9 +5957,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm-gnueabihf": { "node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
"integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -5988,9 +5977,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-gnu": { "node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
"integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -6008,9 +5997,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-musl": { "node_modules/lightningcss-linux-arm64-musl": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
"integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -6028,9 +6017,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
"integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -6048,9 +6037,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-musl": { "node_modules/lightningcss-linux-x64-musl": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
"integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -6068,9 +6057,9 @@
} }
}, },
"node_modules/lightningcss-win32-arm64-msvc": { "node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
"integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -6088,9 +6077,9 @@
} }
}, },
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.29.1", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
"integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -6341,6 +6330,12 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC" "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": { "node_modules/mkdirp": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
@@ -8218,9 +8213,9 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.0.8", "version": "4.0.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.8.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.15.tgz",
"integrity": "sha512-Me7N5CKR+D2A1xdWA5t5+kjjT7bwnxZOE6/yDI/ixJdJokszsn2n++mdU5yJwrsTpqFX2B9ZNMBJDwcqk9C9lw==", "integrity": "sha512-6ZMg+hHdMJpjpeCCFasX7K+U615U9D+7k5/cDK/iRwl6GptF24+I/AbKgOnXhVKePzrEyIXutLv36n4cRsq3Sg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {

View File

@@ -15,7 +15,7 @@
"@fontsource/inter": "^5.1.1", "@fontsource/inter": "^5.1.1",
"@mui/icons-material": "^6.4.7", "@mui/icons-material": "^6.4.7",
"@mui/joy": "^5.0.0-beta.51", "@mui/joy": "^5.0.0-beta.51",
"@tailwindcss/vite": "^4.0.8", "@tailwindcss/vite": "^4.0.15",
"@tiptap/extension-link": "^2.11.5", "@tiptap/extension-link": "^2.11.5",
"@tiptap/pm": "^2.11.5", "@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5", "@tiptap/react": "^2.11.5",
@@ -31,6 +31,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"is-stream": "^4.0.1", "is-stream": "^4.0.1",
"make-dir": "^5.0.0", "make-dir": "^5.0.0",
"mitt": "^3.0.1",
"mongoose": "^8.12.1", "mongoose": "^8.12.1",
"node-ssh": "^13.2.0", "node-ssh": "^13.2.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
@@ -43,7 +44,7 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"tailwindcss": "^4.0.8" "tailwindcss": "^4.0.15"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
import { NewTerminal } from "./apps/ssh/Terminal.jsx"; import { NewTerminal } from "./apps/ssh/Terminal.jsx";
import { User } from "./apps/user/User.jsx"; import { User } from "./apps/user/User.jsx";
import AddHostModal from "./modals/AddHostModal.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 { Button } from "@mui/joy";
import { CssVarsProvider } from "@mui/joy"; import { CssVarsProvider } from "@mui/joy";
import theme from "./theme"; import theme from "./theme";
@@ -12,16 +12,15 @@ import { Debounce } from './other/Utils.jsx';
import TermixIcon from "./images/termix_icon.png"; import TermixIcon from "./images/termix_icon.png";
import RocketIcon from './images/launchpad_rocket.png'; import RocketIcon from './images/launchpad_rocket.png';
import ProfileIcon from './images/profile_icon.png'; import ProfileIcon from './images/profile_icon.png';
import CreateUserModal from "./modals/CreateUserModal.jsx";
import ProfileModal from "./modals/ProfileModal.jsx"; import ProfileModal from "./modals/ProfileModal.jsx";
import ErrorModal from "./modals/ErrorModal.jsx"; import ErrorModal from "./modals/ErrorModal.jsx";
import EditHostModal from "./modals/EditHostModal.jsx"; import EditHostModal from "./modals/EditHostModal.jsx";
import NoAuthenticationModal from "./modals/NoAuthenticationModal.jsx"; import NoAuthenticationModal from "./modals/NoAuthenticationModal.jsx";
import eventBus from "./other/eventBus.jsx";
function App() { function App() {
const [isAddHostHidden, setIsAddHostHidden] = useState(true); const [isAddHostHidden, setIsAddHostHidden] = useState(true);
const [isLoginUserHidden, setIsLoginUserHidden] = useState(true); const [isAuthModalHidden, setIsAuthModalHidden] = useState(true);
const [isCreateUserHidden, setIsCreateUserHidden] = useState(true);
const [isProfileHidden, setIsProfileHidden] = useState(true); const [isProfileHidden, setIsProfileHidden] = useState(true);
const [isErrorHidden, setIsErrorHidden] = useState(true); const [isErrorHidden, setIsErrorHidden] = useState(true);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
@@ -35,10 +34,17 @@ function App() {
ip: "", ip: "",
user: "", user: "",
password: "", password: "",
sshKey: "",
port: 22, port: 22,
authMethod: "Select Auth", authMethod: "Select Auth",
rememberHost: false, rememberHost: true,
storePassword: true, storePassword: true,
connectionType: "ssh",
rdpDomain: "",
rdpWindowsAuthentication: true,
rdpConsole: false,
vncScaling: "100%",
vncQuality: "High"
}); });
const [editHostForm, setEditHostForm] = useState({ const [editHostForm, setEditHostForm] = useState({
name: "", name: "",
@@ -46,6 +52,7 @@ function App() {
ip: "", ip: "",
user: "", user: "",
password: "", password: "",
sshKey: "",
port: 22, port: 22,
authMethod: "Select Auth", authMethod: "Select Auth",
rememberHost: true, rememberHost: true,
@@ -53,23 +60,23 @@ function App() {
}); });
const [isNoAuthHidden, setIsNoAuthHidden] = useState(true); const [isNoAuthHidden, setIsNoAuthHidden] = useState(true);
const [authForm, setAuthForm] = useState({ const [authForm, setAuthForm] = useState({
password: "", username: '',
rsaKey: "", password: '',
}); confirmPassword: ''
const [loginUserForm, setLoginUserForm] = useState({
username: "",
password: "",
});
const [createUserForm, setCreateUserForm] = useState({
username: "",
password: "",
}); });
const [noAuthenticationForm, setNoAuthenticationForm] = useState({
authMethod: 'Select Auth',
password: '',
sshKey: '',
keyType: '',
})
const [isLaunchpadOpen, setIsLaunchpadOpen] = useState(false); const [isLaunchpadOpen, setIsLaunchpadOpen] = useState(false);
const [splitTabIds, setSplitTabIds] = useState([]); const [splitTabIds, setSplitTabIds] = useState([]);
const [isEditHostHidden, setIsEditHostHidden] = useState(true); const [isEditHostHidden, setIsEditHostHidden] = useState(true);
const [currentHostConfig, setCurrentHostConfig] = useState(null); const [currentHostConfig, setCurrentHostConfig] = useState(null);
const [isLoggingIn, setIsLoggingIn] = useState(true); const [isLoggingIn, setIsLoggingIn] = useState(true);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isHostViewerMenuOpen, setIsHostViewerMenuOpen] = useState(null);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
@@ -133,13 +140,13 @@ function App() {
if (userRef.current?.getUser()) { if (userRef.current?.getUser()) {
setIsLoggingIn(false); setIsLoggingIn(false);
setIsLoginUserHidden(true); setIsAuthModalHidden(true);
return; return;
} }
if (!sessionToken) { if (!sessionToken) {
setIsLoggingIn(false); setIsLoggingIn(false);
setIsLoginUserHidden(false); setIsAuthModalHidden(false);
return; return;
} }
@@ -153,7 +160,7 @@ function App() {
clearInterval(attemptLoginInterval); clearInterval(attemptLoginInterval);
if (!userRef.current?.getUser()) { if (!userRef.current?.getUser()) {
localStorage.removeItem('sessionToken'); localStorage.removeItem('sessionToken');
setIsLoginUserHidden(false); setIsAuthModalHidden(false);
setIsLoggingIn(false); setIsLoggingIn(false);
setErrorMessage('Login timed out. Please try again.'); setErrorMessage('Login timed out. Please try again.');
setIsErrorHidden(false); setIsErrorHidden(false);
@@ -170,7 +177,7 @@ function App() {
if (!userRef.current?.getUser()) { if (!userRef.current?.getUser()) {
localStorage.removeItem('sessionToken'); localStorage.removeItem('sessionToken');
setIsLoginUserHidden(false); setIsAuthModalHidden(false);
setIsLoggingIn(false); setIsLoggingIn(false);
setErrorMessage('Login timed out. Please try again.'); setErrorMessage('Login timed out. Please try again.');
setIsErrorHidden(false); setIsErrorHidden(false);
@@ -186,7 +193,7 @@ function App() {
if (isComponentMounted) { if (isComponentMounted) {
clearTimeout(loginTimeout); clearTimeout(loginTimeout);
clearInterval(attemptLoginInterval); clearInterval(attemptLoginInterval);
setIsLoginUserHidden(true); setIsAuthModalHidden(true);
setIsLoggingIn(false); setIsLoggingIn(false);
setIsErrorHidden(true); setIsErrorHidden(true);
} }
@@ -200,7 +207,7 @@ function App() {
localStorage.removeItem('sessionToken'); localStorage.removeItem('sessionToken');
setErrorMessage(`Auto-login failed: ${error}`); setErrorMessage(`Auto-login failed: ${error}`);
setIsErrorHidden(false); setIsErrorHidden(false);
setIsLoginUserHidden(false); setIsAuthModalHidden(false);
setIsLoggingIn(false); setIsLoggingIn(false);
} }
} }
@@ -222,34 +229,53 @@ function App() {
}, []); }, []);
const handleAddHost = () => { 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) { if (!addHostForm.rememberHost) {
connectToHost(); connectToHost();
setIsAddHostHidden(true); setIsAddHostHidden(true);
return; return;
} }
if (addHostForm.connectionType === 'ssh') {
if (addHostForm.authMethod === 'Select Auth') { if (addHostForm.authMethod === 'Select Auth') {
alert("Please select an authentication method."); setErrorMessage("Please select an authentication method.");
setIsErrorHidden(false);
return; return;
} }
if (addHostForm.authMethod === 'password' && !addHostForm.password) { if (addHostForm.authMethod === 'password' && !addHostForm.password) {
setIsNoAuthHidden(false); setIsNoAuthHidden(false);
return; return;
} }
if (addHostForm.authMethod === 'rsaKey' && !addHostForm.rsaKey) { if (addHostForm.authMethod === 'sshKey' && !addHostForm.sshKey) {
setIsNoAuthHidden(false);
return;
}
}
else if (!addHostForm.password) {
setIsNoAuthHidden(false); setIsNoAuthHidden(false);
return; return;
} }
try {
connectToHost(); connectToHost();
if (!addHostForm.storePassword) { if (!addHostForm.storePassword) {
addHostForm.password = ''; addHostForm.password = '';
} }
handleSaveHost(); handleSaveHost();
setIsAddHostHidden(true); setIsAddHostHidden(true);
} catch (error) {
setErrorMessage(error.message || "Failed to add host");
setIsErrorHidden(false);
}
} else { } 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, user: addHostForm.user,
port: String(addHostForm.port), port: String(addHostForm.port),
password: addHostForm.rememberHost && addHostForm.authMethod === 'password' ? addHostForm.password : undefined, 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 = { const newTerminal = {
@@ -274,25 +300,42 @@ function App() {
setActiveTab(nextId); setActiveTab(nextId);
setNextId(nextId + 1); setNextId(nextId + 1);
setIsAddHostHidden(true); 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 handleAuthSubmit = (form) => {
try {
setIsNoAuthHidden(true);
setTimeout(() => {
const updatedTerminals = terminals.map((terminal) => { const updatedTerminals = terminals.map((terminal) => {
if (terminal.id === activeTab) { if (terminal.id === activeTab) {
return { return {
...terminal, ...terminal,
hostConfig: { hostConfig: {
...terminal.hostConfig, ...terminal.hostConfig,
password: form.password, password: form.authMethod === 'password' ? form.password : undefined,
rsaKey: form.rsaKey sshKey: form.authMethod === 'sshKey' ? form.sshKey : undefined
} }
}; };
} }
return terminal; return terminal;
}); });
setTerminals(updatedTerminals); setTerminals(updatedTerminals);
setIsNoAuthHidden(true);
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) => { const connectToHostWithConfig = (hostConfig) => {
@@ -311,7 +354,7 @@ function App() {
user: hostConfig.user.trim(), user: hostConfig.user.trim(),
port: hostConfig.port || '22', port: hostConfig.port || '22',
password: hostConfig.password?.trim(), password: hostConfig.password?.trim(),
rsaKey: hostConfig.rsaKey?.trim(), sshKey: hostConfig.sshKey?.trim(),
}; };
const newTerminal = { const newTerminal = {
@@ -326,21 +369,31 @@ function App() {
setIsLaunchpadOpen(false); setIsLaunchpadOpen(false);
} }
const handleSaveHost = () => { const handleSaveHost = async () => {
try {
let hostConfig = { let hostConfig = {
name: addHostForm.name || addHostForm.ip, name: addHostForm.name || addHostForm.ip,
folder: addHostForm.folder, folder: addHostForm.folder,
ip: addHostForm.ip, ip: addHostForm.ip,
user: addHostForm.user, user: addHostForm.user,
password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined, password: (addHostForm.authMethod === 'password' || addHostForm.connectionType === 'vnc' || addHostForm.connectionType === 'rdp') ? addHostForm.password : undefined,
rsaKey: addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined, sshKey: addHostForm.connectionType === 'ssh' && addHostForm.authMethod === 'sshKey' ? addHostForm.sshKey : undefined,
port: String(addHostForm.port), 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) { if (userRef.current) {
userRef.current.saveHost({ await userRef.current.saveHost({
hostConfig, hostConfig,
}); });
} }
} catch (error) {
throw error;
}
} }
const handleLoginUser = ({ username, password, sessionToken, onSuccess, onFailure }) => { const handleLoginUser = ({ username, password, sessionToken, onSuccess, onFailure }) => {
@@ -349,13 +402,13 @@ function App() {
userRef.current.loginUser({ userRef.current.loginUser({
sessionToken, sessionToken,
onSuccess: () => { onSuccess: () => {
setIsLoginUserHidden(true); setIsAuthModalHidden(true);
setIsLoggingIn(false); setIsLoggingIn(false);
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();
}, },
onFailure: (error) => { onFailure: (error) => {
localStorage.removeItem('sessionToken'); localStorage.removeItem('sessionToken');
setIsLoginUserHidden(false); setIsAuthModalHidden(false);
setIsLoggingIn(false); setIsLoggingIn(false);
if (onFailure) onFailure(error); if (onFailure) onFailure(error);
}, },
@@ -365,12 +418,12 @@ function App() {
username, username,
password, password,
onSuccess: () => { onSuccess: () => {
setIsLoginUserHidden(true); setIsAuthModalHidden(true);
setIsLoggingIn(false); setIsLoggingIn(false);
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();
}, },
onFailure: (error) => { onFailure: (error) => {
setIsLoginUserHidden(false); setIsAuthModalHidden(false);
setIsLoggingIn(false); setIsLoggingIn(false);
if (onFailure) onFailure(error); if (onFailure) onFailure(error);
}, },
@@ -454,9 +507,11 @@ function App() {
}); });
await new Promise(resolve => setTimeout(resolve, 3000)); await new Promise(resolve => setTimeout(resolve, 3000));
setIsEditHostHidden(true);
} catch (error) {
throw error;
} finally { } finally {
setIsEditing(false); setIsEditing(false);
setIsEditHostHidden(true);
} }
return; return;
} }
@@ -464,7 +519,7 @@ function App() {
updateEditHostForm(oldConfig); updateEditHostForm(oldConfig);
} catch (error) { } catch (error) {
console.error('Edit failed:', error); console.error('Edit failed:', error);
setErrorMessage(`Edit failed: ${error}`); setErrorMessage(`Edit failed: ${error.message || error}`);
setIsErrorHidden(false); setIsErrorHidden(false);
setIsEditing(false); setIsEditing(false);
} }
@@ -585,7 +640,7 @@ function App() {
{/* Profile Button */} {/* Profile Button */}
<Button <Button
disabled={isLoggingIn} disabled={isLoggingIn}
onClick={() => userRef.current?.getUser() ? setIsProfileHidden(false) : setIsLoginUserHidden(false)} onClick={() => userRef.current?.getUser() ? setIsProfileHidden(false) : setIsAuthModalHidden(false)}
sx={{ sx={{
backgroundColor: theme.palette.general.tertiary, backgroundColor: theme.palette.general.tertiary,
"&:hover": { backgroundColor: theme.palette.general.secondary }, "&:hover": { backgroundColor: theme.palette.general.secondary },
@@ -633,6 +688,8 @@ function App() {
hostConfig={terminal.hostConfig} hostConfig={terminal.hostConfig}
isVisible={activeTab === terminal.id || splitTabIds.includes(terminal.id)} isVisible={activeTab === terminal.id || splitTabIds.includes(terminal.id)}
setIsNoAuthHidden={setIsNoAuthHidden} setIsNoAuthHidden={setIsNoAuthHidden}
setErrorMessage={setErrorMessage}
setIsErrorHidden={setIsErrorHidden}
ref={(ref) => { ref={(ref) => {
terminal.terminalRef = ref; terminal.terminalRef = ref;
}} }}
@@ -649,8 +706,8 @@ function App() {
)} )}
<NoAuthenticationModal <NoAuthenticationModal
isHidden={isNoAuthHidden} isHidden={isNoAuthHidden}
form={authForm} form={noAuthenticationForm}
setForm={setAuthForm} setForm={setNoAuthenticationForm}
setIsNoAuthHidden={setIsNoAuthHidden} setIsNoAuthHidden={setIsNoAuthHidden}
handleAuthSubmit={handleAuthSubmit} handleAuthSubmit={handleAuthSubmit}
/> />
@@ -694,6 +751,8 @@ function App() {
editHost={handleEditHost} editHost={handleEditHost}
shareHost={(hostId, username) => userRef.current?.shareHost(hostId, username)} shareHost={(hostId, username) => userRef.current?.shareHost(hostId, username)}
userRef={userRef} userRef={userRef}
isHostViewerMenuOpen={isHostViewerMenuOpen}
setIsHostViewerMenuOpen={setIsHostViewerMenuOpen}
/> />
)} )}
</> </>
@@ -705,40 +764,31 @@ function App() {
setIsErrorHidden={setIsErrorHidden} setIsErrorHidden={setIsErrorHidden}
/> />
<LoginUserModal <AuthModal
isHidden={isLoginUserHidden} isHidden={isAuthModalHidden}
form={loginUserForm} form={authForm}
setForm={setLoginUserForm} setForm={setAuthForm}
handleLoginUser={handleLoginUser} handleLoginUser={handleLoginUser}
handleGuestLogin={handleGuestLogin}
setIsLoginUserHidden={setIsLoginUserHidden}
setIsCreateUserHidden={setIsCreateUserHidden}
/>
<CreateUserModal
isHidden={isCreateUserHidden}
form={createUserForm}
setForm={setCreateUserForm}
handleCreateUser={handleCreateUser} handleCreateUser={handleCreateUser}
setIsCreateUserHidden={setIsCreateUserHidden} handleGuestLogin={handleGuestLogin}
setIsLoginUserHidden={setIsLoginUserHidden} setIsAuthModalHidden={setIsAuthModalHidden}
/> />
{/* User component */} {/* User component */}
<User <User
ref={userRef} ref={userRef}
onLoginSuccess={() => { onLoginSuccess={() => {
setIsLoginUserHidden(true); setIsAuthModalHidden(true);
setIsLoggingIn(false); setIsLoggingIn(false);
setIsErrorHidden(true); setIsErrorHidden(true);
}} }}
onCreateSuccess={() => { onCreateSuccess={() => {
setIsCreateUserHidden(true); setIsAuthModalHidden(true);
handleLoginUser({ handleLoginUser({
username: createUserForm.username, username: authForm.username,
password: createUserForm.password, password: authForm.password,
onSuccess: () => { onSuccess: () => {
setIsLoginUserHidden(true); setIsAuthModalHidden(true);
setIsLoggingIn(false); setIsLoggingIn(false);
setIsErrorHidden(true); setIsErrorHidden(true);
}, },
@@ -756,6 +806,7 @@ function App() {
setErrorMessage(`Action failed: ${error}`); setErrorMessage(`Action failed: ${error}`);
setIsErrorHidden(false); setIsErrorHidden(false);
setIsLoggingIn(false); setIsLoggingIn(false);
eventBus.emit('failedLoginUser');
}} }}
/> />
</div> </div>

View File

@@ -18,6 +18,8 @@ function Launchpad({
editHost, editHost,
shareHost, shareHost,
userRef, userRef,
isHostViewerMenuOpen,
setIsHostViewerMenuOpen,
}) { }) {
const launchpadRef = useRef(null); const launchpadRef = useRef(null);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -32,6 +34,7 @@ function Launchpad({
isAddHostHidden && isAddHostHidden &&
isEditHostHidden && isEditHostHidden &&
isErrorHidden && isErrorHidden &&
!isHostViewerMenuOpen &&
!isAnyModalOpen !isAnyModalOpen
) { ) {
onClose(); onClose();
@@ -43,7 +46,7 @@ function Launchpad({
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
}; };
}, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden, isAnyModalOpen]); }, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden, isHostViewerMenuOpen, isAnyModalOpen]);
const handleModalOpen = () => { const handleModalOpen = () => {
setIsAnyModalOpen(true); setIsAnyModalOpen(true);
@@ -190,6 +193,8 @@ function Launchpad({
onModalOpen={handleModalOpen} onModalOpen={handleModalOpen}
onModalClose={handleModalClose} onModalClose={handleModalClose}
userRef={userRef} userRef={userRef}
isMenuOpen={isHostViewerMenuOpen || false}
setIsMenuOpen={setIsHostViewerMenuOpen}
/> />
)} )}
</div> </div>
@@ -211,6 +216,8 @@ Launchpad.propTypes = {
editHost: PropTypes.func.isRequired, editHost: PropTypes.func.isRequired,
shareHost: PropTypes.func.isRequired, shareHost: PropTypes.func.isRequired,
userRef: PropTypes.object.isRequired, userRef: PropTypes.object.isRequired,
isHostViewerMenuOpen: PropTypes.bool,
setIsHostViewerMenuOpen: PropTypes.func.isRequired,
}; };
export default Launchpad; export default Launchpad;

View File

@@ -1,9 +1,22 @@
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Button, Input } from "@mui/joy"; import { Button, Input, Menu, MenuItem, IconButton } from "@mui/joy";
import ShareHostModal from "../../modals/ShareHostModal"; import ShareHostModal from "../../modals/ShareHostModal";
function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, editHost, openEditPanel, shareHost, onModalOpen, onModalClose, userRef }) { function HostViewer({
getHosts,
connectToHost,
setIsAddHostHidden,
deleteHost,
editHost,
openEditPanel,
shareHost,
onModalOpen,
onModalClose,
userRef,
isMenuOpen,
setIsMenuOpen,
}) {
const [hosts, setHosts] = useState([]); const [hosts, setHosts] = useState([]);
const [filteredHosts, setFilteredHosts] = useState([]); const [filteredHosts, setFilteredHosts] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -15,6 +28,24 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isShareModalHidden, setIsShareModalHidden] = useState(true); const [isShareModalHidden, setIsShareModalHidden] = useState(true);
const [selectedHostForShare, setSelectedHostForShare] = useState(null); const [selectedHostForShare, setSelectedHostForShare] = useState(null);
const [selectedHost, setSelectedHost] = useState(null);
const anchorEl = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target) && anchorEl.current && !anchorEl.current.contains(event.target)) {
setIsMenuOpen(false);
setSelectedHost(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const fetchHosts = async () => { const fetchHosts = async () => {
try { try {
@@ -229,7 +260,8 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
className="text-black" variant="outlined"
className="text-white"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!hostWrapper.config || !hostWrapper.config.ip || !hostWrapper.config.user) { if (!hostWrapper.config || !hostWrapper.config.ip || !hostWrapper.config.user) {
@@ -242,76 +274,36 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
backgroundColor: "#6e6e6e", backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" }, "&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1, opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer" cursor: isDeleting ? "not-allowed" : "pointer",
borderColor: "#3d3d3d",
borderWidth: "2px",
color: "#fff",
}} }}
> >
Connect Connect
</Button> </Button>
{isOwner && ( <IconButton
<> variant="outlined"
<Button className="text-white"
className="text-black"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedHostForShare(hostWrapper); setSelectedHost(hostWrapper);
setIsShareModalHidden(false); setIsMenuOpen(!isMenuOpen);
anchorEl.current = e.currentTarget;
}} }}
disabled={isDeleting} disabled={isDeleting}
sx={{ sx={{
backgroundColor: "#6e6e6e", backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" }, "&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1, opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer" cursor: isDeleting ? "not-allowed" : "pointer",
borderColor: "#3d3d3d",
borderWidth: "2px",
color: "#fff",
}} }}
> >
Share
</Button> </IconButton>
<Button
className="text-black"
onClick={(e) => handleDelete(e, hostWrapper)}
disabled={isDeleting}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer"
}}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
<Button
className="text-black"
onClick={(e) => {
e.stopPropagation();
openEditPanel(hostConfig);
}}
disabled={isDeleting}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer"
}}
>
Edit
</Button>
</>
)}
{!isOwner && (
<Button
className="text-black"
onClick={(e) => handleDelete(e, hostWrapper)}
disabled={isDeleting}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer"
}}
>
{isDeleting ? "Removing..." : "Remove Share"}
</Button>
)}
</div> </div>
</div> </div>
); );
@@ -352,7 +344,6 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
return ( return (
<> <>
{/* Render hosts without folders first */}
<div <div
className={`flex flex-col gap-2 p-2 rounded-lg transition-colors ${isDraggingOver === 'no-folder' ? 'bg-neutral-700' : ''}`} className={`flex flex-col gap-2 p-2 rounded-lg transition-colors ${isDraggingOver === 'no-folder' ? 'bg-neutral-700' : ''}`}
onDragOver={(e) => handleDragOver(e, 'no-folder')} onDragOver={(e) => handleDragOver(e, 'no-folder')}
@@ -362,7 +353,6 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
{noFolder.map((host) => renderHostItem(host))} {noFolder.map((host) => renderHostItem(host))}
</div> </div>
{/* Render folders and their hosts */}
{sortedFolders.map((folderName) => ( {sortedFolders.map((folderName) => (
<div key={folderName} className="mb-2"> <div key={folderName} className="mb-2">
<div <div
@@ -403,6 +393,68 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
handleShare={handleShare} handleShare={handleShare}
hostConfig={selectedHostForShare} hostConfig={selectedHostForShare}
/> />
<Menu
ref={menuRef}
anchorEl={anchorEl.current}
open={isMenuOpen}
onClose={() => {
setIsMenuOpen(false);
setSelectedHost(null);
}}
sx={{
"& .MuiMenu-list": {
backgroundColor: "#6e6e6e",
color: "white"
}
}}
>
{selectedHost && (
selectedHost.createdBy?._id === userRef.current?.getUser()?.id ? (
<>
<MenuItem
onClick={(e) => {
e.stopPropagation();
setSelectedHostForShare(selectedHost);
setIsShareModalHidden(false);
setIsMenuOpen(false);
}}
>
Share
</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
openEditPanel(selectedHost.config);
setIsMenuOpen(false);
}}
>
Edit
</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(e, selectedHost);
setIsMenuOpen(false);
}}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</MenuItem>
</>
) : (
<MenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(e, selectedHost);
setIsMenuOpen(false);
}}
disabled={isDeleting}
>
{isDeleting ? "Removing..." : "Remove Share"}
</MenuItem>
)
)}
</Menu>
</div> </div>
); );
} }
@@ -418,6 +470,8 @@ HostViewer.propTypes = {
onModalOpen: PropTypes.func.isRequired, onModalOpen: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired,
userRef: PropTypes.object.isRequired, userRef: PropTypes.object.isRequired,
isMenuOpen: PropTypes.bool.isRequired,
setIsMenuOpen: PropTypes.func.isRequired,
}; };
export default HostViewer; export default HostViewer;

View File

@@ -6,7 +6,7 @@ import io from "socket.io-client";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import theme from "../../theme.js"; 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 terminalRef = useRef(null);
const socketRef = useRef(null); const socketRef = useRef(null);
const fitAddon = useRef(new FitAddon()); const fitAddon = useRef(new FitAddon());
@@ -18,8 +18,8 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
if (!parentContainer || parentContainer.clientWidth === 0) return; if (!parentContainer || parentContainer.clientWidth === 0) return;
const parentWidth = parentContainer.clientWidth - 10; const parentWidth = parentContainer.clientWidth - 8;
const parentHeight = parentContainer.clientHeight - 10; const parentHeight = parentContainer.clientHeight - 12;
terminalContainer.style.width = `${parentWidth}px`; terminalContainer.style.width = `${parentWidth}px`;
terminalContainer.style.height = `${parentHeight}px`; terminalContainer.style.height = `${parentHeight}px`;
@@ -50,6 +50,9 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
fontSize: 14, fontSize: 14,
scrollback: 1000, scrollback: 1000,
ignoreBracketedPasteMode: true, ignoreBracketedPasteMode: true,
letterSpacing: 0,
lineHeight: 1,
padding: 2,
}); });
terminalInstance.current.loadAddon(fitAddon.current); terminalInstance.current.loadAddon(fitAddon.current);
@@ -62,21 +65,28 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
{ {
path: "/ssh.io/socket.io", path: "/ssh.io/socket.io",
transports: ["websocket", "polling"], transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
timeout: 20000,
} }
); );
socketRef.current = socket; socketRef.current = socket;
socket.on("connect_error", (error) => { socket.on("connect_error", (error) => {
terminalInstance.current.write(`\r\n*** Socket connection error: ${error.message} ***\r\n`); terminalInstance.current.write(`\r\n*** Socket connection error: ${error.message} ***\r\n`);
console.error("Socket connection error:", error);
}); });
socket.on("connect_timeout", () => { socket.on("connect_timeout", () => {
terminalInstance.current.write(`\r\n*** Socket connection timeout ***\r\n`); terminalInstance.current.write(`\r\n*** Socket connection timeout ***\r\n`);
console.error("Socket connection timeout");
}); });
socket.on("error", (err) => { socket.on("error", (err) => {
console.error("SSH connection error:", err);
const isAuthError = err.toLowerCase().includes("authentication") || err.toLowerCase().includes("auth"); 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; authModalShown = true;
setIsNoAuthHidden(false); setIsNoAuthHidden(false);
} }
@@ -88,7 +98,7 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
resizeTerminal(); resizeTerminal();
const { cols, rows } = terminalInstance.current; const { cols, rows } = terminalInstance.current;
if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim()) { if (!hostConfig.password?.trim() && !hostConfig.sshKey?.trim()) {
setIsNoAuthHidden(false); setIsNoAuthHidden(false);
return; return;
} }
@@ -98,16 +108,19 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
user: hostConfig.user, user: hostConfig.user,
port: Number(hostConfig.port) || 22, port: Number(hostConfig.port) || 22,
password: hostConfig.password?.trim(), 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); socket.emit("connectToHost", cols, rows, sshConfig);
}); });
setTimeout(() => { setTimeout(() => {
if (terminalInstance.current) {
fitAddon.current.fit(); fitAddon.current.fit();
resizeTerminal(); resizeTerminal();
terminalInstance.current.focus(); terminalInstance.current.focus();
}
}, 50); }, 50);
socket.on("data", (data) => { socket.on("data", (data) => {
@@ -117,8 +130,11 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
let isPasting = false; let isPasting = false;
if (terminalInstance.current) {
terminalInstance.current.onData((data) => { terminalInstance.current.onData((data) => {
if (socketRef.current && socketRef.current.connected) {
socketRef.current.emit("data", data); socketRef.current.emit("data", data);
}
}); });
terminalInstance.current.attachCustomKeyEventHandler((event) => { terminalInstance.current.attachCustomKeyEventHandler((event) => {
@@ -127,36 +143,21 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
isPasting = true; isPasting = true;
event.preventDefault(); event.preventDefault();
navigator.clipboard.readText().then(text => {
navigator.clipboard.readText().then((text) => { if (text && socketRef.current?.connected) {
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const processedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\r");
const lines = text.split("\n"); socketRef.current.emit("data", processedText);
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;
} }
}; }).catch(() => {
setErrorMessage("Paste failed: Clipboard access denied. Instead, use Control Shift V.");
sendLine(); setIsErrorHidden(false);
} else { }).finally(() => {
isPasting = false; setTimeout(() => {
}
}).catch((err) => {
console.error("Failed to read clipboard contents:", err);
isPasting = false; isPasting = false;
}, 300);
}); });
return false; return false;
} }
return true; return true;
}); });
@@ -168,17 +169,46 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
} }
} }
}); });
}
let authModalShown = false; let authModalShown = false;
socket.on("noAuthRequired", () => { socket.on("noAuthRequired", () => {
if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) { if (!hostConfig.password?.trim() && !hostConfig.sshKey?.trim() && !authModalShown) {
authModalShown = true; authModalShown = true;
setIsNoAuthHidden(false); 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 () => { return () => {
clearInterval(pingInterval);
if (terminalInstance.current) { if (terminalInstance.current) {
terminalInstance.current.dispose(); terminalInstance.current.dispose();
terminalInstance.current = null; terminalInstance.current = null;
@@ -202,14 +232,21 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
const parentContainer = terminalContainer.parentElement; const parentContainer = terminalContainer.parentElement;
if (!parentContainer) return; if (!parentContainer) return;
const observer = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
resizeTerminal(); resizeTerminal();
}); });
observer.observe(parentContainer); resizeObserver.observe(parentContainer);
const handleWindowResize = () => {
resizeTerminal();
};
window.addEventListener('resize', handleWindowResize);
return () => { return () => {
observer.disconnect(); resizeObserver.disconnect();
window.removeEventListener('resize', handleWindowResize);
}; };
}, []); }, []);
@@ -222,7 +259,7 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde
position: 'absolute', position: 'absolute',
width: '100%', width: '100%',
height: '100%', height: '100%',
transform: 'translateY(5px) translateX(5px)', transform: 'translateY(2px) translateX(3px)',
}} }}
/> />
); );
@@ -235,9 +272,12 @@ NewTerminal.propTypes = {
ip: PropTypes.string.isRequired, ip: PropTypes.string.isRequired,
user: PropTypes.string.isRequired, user: PropTypes.string.isRequired,
password: PropTypes.string, password: PropTypes.string,
sshKey: PropTypes.string,
rsaKey: PropTypes.string, rsaKey: PropTypes.string,
port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
}).isRequired, }).isRequired,
isVisible: PropTypes.bool.isRequired, isVisible: PropTypes.bool.isRequired,
setIsNoAuthHidden: PropTypes.func.isRequired, setIsNoAuthHidden: PropTypes.func.isRequired,
setErrorMessage: PropTypes.func.isRequired,
setIsErrorHidden: PropTypes.func.isRequired,
}; };

View File

@@ -149,6 +149,27 @@ export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSucce
if (!currentUser.current) return onFailure("Not authenticated"); if (!currentUser.current) return onFailure("Not authenticated");
try { 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) => { const response = await new Promise((resolve) => {
socketRef.current.emit("saveHostConfig", { socketRef.current.emit("saveHostConfig", {
userId: currentUser.current.id, userId: currentUser.current.id,
@@ -186,7 +207,7 @@ export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSucce
user: host.config.user || '', user: host.config.user || '',
port: host.config.port || '22', port: host.config.port || '22',
password: host.config.password || '', password: host.config.password || '',
rsaKey: host.config.rsaKey || '', sshKey: host.config.sshKey || '',
} : {} } : {}
})).filter(host => host.config && host.config.ip && host.config.user); })).filter(host => host.config && host.config.ip && host.config.user);
} else { } else {
@@ -222,7 +243,18 @@ export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSucce
if (!currentUser.current) return onFailure("Not authenticated"); if (!currentUser.current) return onFailure("Not authenticated");
try { 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) => { const response = await new Promise((resolve) => {
socketRef.current.emit("editHost", { socketRef.current.emit("editHost", {
userId: currentUser.current.id, userId: currentUser.current.id,

View File

@@ -185,19 +185,36 @@ io.of('/database.io').on('connection', (socket) => {
user: hostConfig.user.trim(), user: hostConfig.user.trim(),
port: hostConfig.port || 22, port: hostConfig.port || 22,
password: hostConfig.password?.trim() || undefined, password: hostConfig.password?.trim() || undefined,
rsaKey: hostConfig.rsaKey?.trim() || undefined sshKey: hostConfig.sshKey?.trim() || undefined,
}; };
const finalName = cleanConfig.name || cleanConfig.ip; const finalName = cleanConfig.name || cleanConfig.ip;
const existingHost = await Host.findOne({ // Check for hosts with the same name (case insensitive)
name: finalName, const existingHostByName = await Host.findOne({
createdBy: userId createdBy: userId,
name: { $regex: new RegExp('^' + finalName + '$', 'i') }
}); });
if (existingHost) { if (existingHostByName) {
logger.warn(`Host with name ${finalName} already exists for user: ${userId}`); 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); const encryptedConfig = encryptData(cleanConfig, userId, sessionToken);
@@ -397,6 +414,7 @@ io.of('/database.io').on('connection', (socket) => {
return callback({ error: 'Invalid session' }); return callback({ error: 'Invalid session' });
} }
// Find the host to be edited
const hosts = await Host.find({ createdBy: userId }); const hosts = await Host.find({ createdBy: userId });
const host = hosts.find(h => { const host = hosts.find(h => {
const decryptedConfig = decryptData(h.config, userId, sessionToken); const decryptedConfig = decryptData(h.config, userId, sessionToken);
@@ -408,6 +426,37 @@ io.of('/database.io').on('connection', (socket) => {
return callback({ error: 'Host not found' }); 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 = { const cleanConfig = {
name: newHostConfig.name?.trim(), name: newHostConfig.name?.trim(),
folder: newHostConfig.folder?.trim() || null, folder: newHostConfig.folder?.trim() || null,
@@ -415,7 +464,7 @@ io.of('/database.io').on('connection', (socket) => {
user: newHostConfig.user.trim(), user: newHostConfig.user.trim(),
port: newHostConfig.port || 22, port: newHostConfig.port || 22,
password: newHostConfig.password?.trim() || undefined, password: newHostConfig.password?.trim() || undefined,
rsaKey: newHostConfig.rsaKey?.trim() || undefined sshKey: newHostConfig.sshKey?.trim() || undefined,
}; };
const encryptedConfig = encryptData(cleanConfig, userId, sessionToken); const encryptedConfig = encryptData(cleanConfig, userId, sessionToken);
@@ -424,6 +473,7 @@ io.of('/database.io').on('connection', (socket) => {
return callback({ error: 'Configuration encryption failed' }); return callback({ error: 'Configuration encryption failed' });
} }
host.name = finalName;
host.config = encryptedConfig; host.config = encryptedConfig;
host.folder = cleanConfig.folder; host.folder = cleanConfig.folder;
await host.save(); await host.save();
@@ -432,7 +482,7 @@ io.of('/database.io').on('connection', (socket) => {
callback({ success: true }); callback({ success: true });
} catch (error) { } catch (error) {
logger.error('Host edit error:', error); logger.error('Host edit error:', error);
callback({ error: 'Failed to edit host' }); callback({ error: `Failed to edit host: ${error.message}` });
} }
}); });

View File

@@ -10,7 +10,10 @@ const io = socketIo(server, {
methods: ["GET", "POST"], methods: ["GET", "POST"],
credentials: true credentials: true
}, },
allowEIO3: true allowEIO3: true,
pingInterval: 2500,
pingTimeout: 5000,
maxHttpBufferSize: 1e7,
}); });
const logger = { const logger = {
@@ -32,7 +35,7 @@ io.on("connection", (socket) => {
return; return;
} }
if (!hostConfig.password && !hostConfig.rsaKey) { if (!hostConfig.password && !hostConfig.sshKey) {
logger.error("No authentication provided"); logger.error("No authentication provided");
socket.emit("error", "Authentication required"); socket.emit("error", "Authentication required");
return; return;
@@ -42,18 +45,18 @@ io.on("connection", (socket) => {
ip: hostConfig.ip, ip: hostConfig.ip,
port: hostConfig.port, port: hostConfig.port,
user: hostConfig.user, user: hostConfig.user,
authType: hostConfig.password ? 'password' : 'public key', authType: hostConfig.password ? 'password' : 'key',
}; };
logger.info("Connecting with config:", safeHostConfig); logger.info("Connecting with config:", safeHostConfig);
const { ip, port, user, password, rsaKey } = hostConfig; const { ip, port, user, password, sshKey, } = hostConfig;
const conn = new SSHClient(); const conn = new SSHClient();
conn conn
.on("ready", function () { .on("ready", function () {
logger.info("SSH connection established"); 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) { if (err) {
logger.error("Shell error:", err.message); logger.error("Shell error:", err.message);
socket.emit("error", err.message); socket.emit("error", err.message);
@@ -93,12 +96,22 @@ io.on("connection", (socket) => {
logger.error("Error:", err.message); logger.error("Error:", err.message);
socket.emit("error", err.message); socket.emit("error", err.message);
}) })
.on("ping", function () {
socket.emit("ping");
})
.connect({ .connect({
host: ip, host: ip,
port: port, port: port,
username: user, username: user,
password: password, password: password || undefined,
privateKey: rsaKey ? Buffer.from(rsaKey) : 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,
}); });
}); });

View File

@@ -7,8 +7,6 @@ import {
FormLabel, FormLabel,
Input, Input,
Stack, Stack,
DialogTitle,
DialogContent,
ModalDialog, ModalDialog,
Select, Select,
Option, Option,
@@ -27,19 +25,51 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff';
const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidden }) => { const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidden }) => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [errorMessage, setErrorMessage] = useState("");
const [showError, setShowError] = useState(false);
const handleFileChange = (e) => { const handleFileChange = (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { const supportedKeyTypes = {
if (file.name.endsWith('.key') || file.name.endsWith('.pem') || file.name.endsWith('.pub')) { '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(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
setForm({ ...form, rsaKey: event.target.result }); 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); reader.readAsText(file);
} else { } else {
alert("Please upload a valid public key file."); 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, ...prev,
authMethod: newMethod, authMethod: newMethod,
password: "", password: "",
rsaKey: "" sshKey: "",
keyType: "",
})); }));
}; };
const isFormValid = () => { const isFormValid = () => {
if (!form.ip || !form.user || !form.port) return false; const { ip, user, port, authMethod, password, sshKey } = form;
const portNum = Number(form.port);
if (!ip?.trim() || !user?.trim() || !port) return false;
const portNum = Number(port);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false; if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false;
if (!form.rememberHost) return true;
if (form.rememberHost) { if (form.rememberHost) {
if (form.authMethod === 'Select Auth') return false; if (authMethod === 'Select Auth') return false;
if (form.authMethod === 'rsaKey' && !form.rsaKey) return false; if (authMethod === 'password' && !password?.trim()) return false;
if (form.authMethod === 'password' && !form.password) return false; if (authMethod === 'sshKey' && !sshKey?.trim()) return false;
} }
return true; return true;
@@ -68,26 +104,29 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
const handleSubmit = (event) => { const handleSubmit = (event) => {
event.preventDefault(); event.preventDefault();
if (isFormValid()) {
if (!form.rememberHost) { setErrorMessage("");
handleAddHost(); setShowError(false);
} else {
handleAddHost(); if (!form.ip?.trim()) {
setErrorMessage("Please provide an IP address.");
setShowError(true);
return;
} }
setForm({ if (form.connectionType === 'ssh' && !form.user?.trim()) {
name: '', setErrorMessage("Please provide a username for SSH connection.");
folder: '', setShowError(true);
ip: '', return;
user: '', }
password: '',
rsaKey: '', try {
port: 22, handleAddHost();
authMethod: 'Select Auth', setActiveTab(0);
rememberHost: false, } catch (error) {
storePassword: true, console.error("Add host error:", error);
}); setErrorMessage(error.message || "Failed to add host. The host name or IP may already exist.");
setIsAddHostHidden(true); setShowError(true);
} }
}; };
@@ -98,15 +137,18 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backdropFilter: 'blur(5px)',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
}} }}
> >
<ModalDialog <ModalDialog
layout="center" layout="center"
variant="outlined"
sx={{ sx={{
backgroundColor: theme.palette.general.tertiary, backgroundColor: theme.palette.general.tertiary,
borderColor: theme.palette.general.secondary, borderColor: theme.palette.general.secondary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
padding: 3, padding: 0,
borderRadius: 10, borderRadius: 10,
maxWidth: '500px', maxWidth: '500px',
width: '100%', width: '100%',
@@ -116,47 +158,57 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
mx: 2, mx: 2,
}} }}
> >
<DialogTitle sx={{ mb: 2 }}>Add Host</DialogTitle> {showError && (
<DialogContent> <div style={{
<form onSubmit={handleSubmit}> backgroundColor: "#c53030",
color: "white",
padding: "10px",
textAlign: "center",
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px"
}}>
{errorMessage}
</div>
)}
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={(e, val) => setActiveTab(val)} onChange={(e, val) => setActiveTab(val)}
sx={{ sx={{
backgroundColor: theme.palette.general.disabled,
borderRadius: '8px',
padding: '8px',
marginBottom: '16px',
width: '100%', width: '100%',
mb: 0,
backgroundColor: theme.palette.general.tertiary,
}} }}
> >
<TabList <TabList
sx={{ sx={{
width: '100%', width: '100%',
gap: 0, gap: 0,
mb: 2, borderTopLeftRadius: 10,
borderTopRightRadius: 10,
backgroundColor: theme.palette.general.primary,
'& button': { '& button': {
flex: 1, flex: 1,
bgcolor: 'transparent', bgcolor: 'transparent',
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
'&:hover': { '&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.1)', bgcolor: theme.palette.general.disabled,
}, },
'&.Mui-selected': { '&.Mui-selected': {
bgcolor: theme.palette.general.primary, bgcolor: theme.palette.general.tertiary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
'&:hover': { '&:hover': {
bgcolor: theme.palette.general.primary, bgcolor: theme.palette.general.tertiary,
}, },
}, },
}, },
}} }}
> >
<Tab>Basic Info</Tab> <Tab sx={{ flex: 1 }}>Basic Info</Tab>
<Tab>Connection</Tab> <Tab sx={{ flex: 1 }}>Connection</Tab>
<Tab>Authentication</Tab> <Tab sx={{ flex: 1 }}>Authentication</Tab>
</TabList> </TabList>
<div style={{ padding: '24px', backgroundColor: theme.palette.general.tertiary }}>
<TabPanel value={0}> <TabPanel value={0}>
<Stack spacing={2}> <Stack spacing={2}>
<FormControl> <FormControl>
@@ -181,6 +233,22 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
}} }}
/> />
</FormControl> </FormControl>
<FormControl>
<FormLabel>Remember Host</FormLabel>
<Checkbox
checked={Boolean(form.rememberHost)}
onChange={(e) => setForm({
...form,
rememberHost: e.target.checked,
})}
sx={{
color: theme.palette.text.primary,
'&.Mui-checked': {
color: theme.palette.text.primary,
},
}}
/>
</FormControl>
</Stack> </Stack>
</TabPanel> </TabPanel>
@@ -230,44 +298,6 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
<TabPanel value={2}> <TabPanel value={2}>
<Stack spacing={2}> <Stack spacing={2}>
<FormControl>
<FormLabel>Remember Host</FormLabel>
<Checkbox
checked={form.rememberHost}
onChange={(e) => setForm({
...form,
rememberHost: e.target.checked,
...((!e.target.checked) && {
authMethod: 'Select Auth',
password: '',
rsaKey: '',
storePassword: true
})
})}
sx={{
color: theme.palette.text.primary,
'&.Mui-checked': {
color: theme.palette.text.primary,
},
}}
/>
</FormControl>
{form.rememberHost && (
<>
<FormControl>
<FormLabel>Store Password</FormLabel>
<Checkbox
checked={form.storePassword}
onChange={(e) => setForm({ ...form, storePassword: e.target.checked })}
sx={{
color: theme.palette.text.primary,
'&.Mui-checked': {
color: theme.palette.text.primary,
},
}}
/>
</FormControl>
<FormControl error={!form.authMethod || form.authMethod === 'Select Auth'}> <FormControl error={!form.authMethod || form.authMethod === 'Select Auth'}>
<FormLabel>Authentication Method</FormLabel> <FormLabel>Authentication Method</FormLabel>
<Select <Select
@@ -280,7 +310,7 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
> >
<Option value="Select Auth" disabled>Select Auth</Option> <Option value="Select Auth" disabled>Select Auth</Option>
<Option value="password">Password</Option> <Option value="password">Password</Option>
<Option value="rsaKey">Public Key</Option> <Option value="sshKey">SSH Key</Option>
</Select> </Select>
</FormControl> </FormControl>
@@ -311,9 +341,10 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
</FormControl> </FormControl>
)} )}
{form.authMethod === 'rsaKey' && ( {form.authMethod === 'sshKey' && (
<FormControl error={!form.rsaKey}> <Stack spacing={2}>
<FormLabel>Public Key</FormLabel> <FormControl error={!form.sshKey}>
<FormLabel>SSH Key</FormLabel>
<Button <Button
component="label" component="label"
sx={{ sx={{
@@ -329,7 +360,7 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
}, },
}} }}
> >
{form.rsaKey ? 'Change Public Key File' : 'Upload Public Key File'} {form.sshKey ? `Change ${form.keyType || 'SSH'} Key File` : 'Upload SSH Key File'}
<Input <Input
type="file" type="file"
onChange={handleFileChange} onChange={handleFileChange}
@@ -337,15 +368,30 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
/> />
</Button> </Button>
</FormControl> </FormControl>
</Stack>
)} )}
</>
{form.rememberHost && (
<FormControl>
<FormLabel>Store Password</FormLabel>
<Checkbox
checked={Boolean(form.storePassword)}
onChange={(e) => setForm({ ...form, storePassword: e.target.checked })}
sx={{
color: theme.palette.text.primary,
'&.Mui-checked': {
color: theme.palette.text.primary,
},
}}
/>
</FormControl>
)} )}
</Stack> </Stack>
</TabPanel> </TabPanel>
</Tabs> </div>
<Button <Button
type="submit" onClick={handleSubmit}
disabled={!isFormValid()} disabled={!isFormValid()}
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
@@ -357,15 +403,14 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
backgroundColor: 'rgba(255, 255, 255, 0.1)', backgroundColor: 'rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.3)', color: 'rgba(255, 255, 255, 0.3)',
}, },
marginTop: 3, marginTop: 1,
width: '100%', width: '100%',
height: '40px', height: '40px',
}} }}
> >
Add Host Add Host
</Button> </Button>
</form> </Tabs>
</DialogContent>
</ModalDialog> </ModalDialog>
</Modal> </Modal>
</CssVarsProvider> </CssVarsProvider>
@@ -374,18 +419,7 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
AddHostModal.propTypes = { AddHostModal.propTypes = {
isHidden: PropTypes.bool.isRequired, isHidden: PropTypes.bool.isRequired,
form: PropTypes.shape({ form: PropTypes.object.isRequired,
name: PropTypes.string,
folder: PropTypes.string,
ip: PropTypes.string.isRequired,
user: PropTypes.string.isRequired,
password: PropTypes.string,
rsaKey: PropTypes.string,
port: PropTypes.number.isRequired,
authMethod: PropTypes.string.isRequired,
rememberHost: PropTypes.bool,
storePassword: PropTypes.bool,
}).isRequired,
setForm: PropTypes.func.isRequired, setForm: PropTypes.func.isRequired,
handleAddHost: PropTypes.func.isRequired, handleAddHost: PropTypes.func.isRequired,
setIsAddHostHidden: PropTypes.func.isRequired, setIsAddHostHidden: PropTypes.func.isRequired,

306
src/modals/AuthModal.jsx Normal file
View File

@@ -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 (
<CssVarsProvider theme={theme}>
<Modal open={!isHidden} onClose={() => setIsAuthModalHidden(true)}>
<ModalDialog
layout="center"
variant="outlined"
sx={{
backgroundColor: theme.palette.general.tertiary,
borderColor: theme.palette.general.secondary,
color: theme.palette.text.primary,
padding: 0,
borderRadius: 10,
maxWidth: '400px',
width: '100%',
overflow: 'hidden',
}}
>
<Tabs
value={activeTab}
onChange={(e, val) => setActiveTab(val)}
sx={{
width: '100%',
backgroundColor: theme.palette.general.tertiary,
}}
>
<TabList
sx={{
gap: 0,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
backgroundColor: theme.palette.general.primary,
'& button': {
flex: 1,
bgcolor: 'transparent',
color: theme.palette.text.secondary,
'&:hover': {
bgcolor: theme.palette.general.disabled,
},
'&.Mui-selected': {
bgcolor: theme.palette.general.tertiary,
color: theme.palette.text.primary,
'&:hover': {
bgcolor: theme.palette.general.tertiary,
},
},
},
}}
>
<Tab sx={{ flex: 1 }}>Login</Tab>
<Tab sx={{ flex: 1 }}>Create</Tab>
</TabList>
<DialogContent sx={{ padding: 3, backgroundColor: theme.palette.general.tertiary }}>
<TabPanel value={0} sx={{ p: 0 }}>
<Stack spacing={2} component="form" onSubmit={(e) => { e.preventDefault(); handleLogin(); }}>
<FormControl>
<FormLabel>Username</FormLabel>
<Input
disabled={isLoading}
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
sx={inputStyle}
/>
</FormControl>
<FormControl>
<FormLabel>Password</FormLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Input
disabled={isLoading}
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
sx={{ ...inputStyle, flex: 1 }}
/>
<IconButton
disabled={isLoading}
onClick={() => setShowPassword(!showPassword)}
sx={iconButtonStyle}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</div>
</FormControl>
<Button
type="submit"
disabled={!isLoginValid || isLoading}
sx={buttonStyle}
>
{isLoading ? "Logging in..." : "Login"}
</Button>
<Button
disabled={isLoading}
onClick={handleGuest}
sx={buttonStyle}
>
{isLoading ? "Logging in..." : "Continue as Guest"}
</Button>
</Stack>
</TabPanel>
<TabPanel value={1} sx={{ p: 0 }}>
<Stack spacing={2} component="form" onSubmit={(e) => { e.preventDefault(); handleCreate(); }}>
<FormControl>
<FormLabel>Username</FormLabel>
<Input
disabled={isLoading}
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
sx={inputStyle}
/>
</FormControl>
<FormControl>
<FormLabel>Password</FormLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Input
disabled={isLoading}
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
sx={{ ...inputStyle, flex: 1 }}
/>
<IconButton
disabled={isLoading}
onClick={() => setShowPassword(!showPassword)}
sx={iconButtonStyle}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</div>
</FormControl>
<FormControl>
<FormLabel>Confirm Password</FormLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Input
disabled={isLoading}
type={showConfirmPassword ? 'text' : 'password'}
value={form.confirmPassword || ''}
onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })}
sx={{ ...inputStyle, flex: 1 }}
/>
<IconButton
disabled={isLoading}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
sx={iconButtonStyle}
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</div>
</FormControl>
<Button
type="submit"
disabled={!isCreateValid || isLoading}
sx={buttonStyle}
>
{isLoading ? "Creating..." : "Create Account"}
</Button>
</Stack>
</TabPanel>
</DialogContent>
</Tabs>
</ModalDialog>
</Modal>
</CssVarsProvider>
);
};
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;

View File

@@ -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 (
<CssVarsProvider theme={theme}>
<Modal open={!isHidden} onClose={() => {}}>
<ModalDialog
layout="center"
sx={{
backgroundColor: theme.palette.general.tertiary,
borderColor: theme.palette.general.secondary,
color: theme.palette.text.primary,
padding: 3,
borderRadius: 10,
width: "auto",
maxWidth: "90vw",
minWidth: "fit-content",
overflow: "hidden",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<DialogTitle>Create</DialogTitle>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault();
if (isFormValid()) handleCreate();
}}
>
<Stack spacing={2} sx={{ width: "100%", maxWidth: "100%", overflow: "hidden" }}>
<FormControl>
<FormLabel>Username</FormLabel>
<Input
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary,
}}
/>
</FormControl>
<FormControl>
<FormLabel>Password</FormLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Input
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary,
flex: 1,
}}
/>
<IconButton
onClick={() => setShowPassword(!showPassword)}
sx={{
color: theme.palette.text.primary,
marginLeft: 1,
}}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</div>
</FormControl>
<FormControl>
<FormLabel>Confirm Password</FormLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary,
flex: 1,
}}
/>
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
sx={{
color: theme.palette.text.primary,
marginLeft: 1,
}}
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</div>
</FormControl>
<Button
type="submit"
disabled={!isFormValid()}
sx={{
backgroundColor: theme.palette.general.primary,
'&:hover': {
backgroundColor: theme.palette.general.disabled,
},
}}
>
Create
</Button>
<Button
onClick={() => {
setForm({ username: '', password: '' });
setConfirmPassword('');
setIsCreateUserHidden(true);
setIsLoginUserHidden(false);
}}
sx={{
backgroundColor: theme.palette.general.primary,
'&:hover': {
backgroundColor: theme.palette.general.disabled,
},
}}
>
Back
</Button>
</Stack>
</form>
</DialogContent>
</ModalDialog>
</Modal>
</CssVarsProvider>
);
};
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;

View File

@@ -8,8 +8,6 @@ import {
FormLabel, FormLabel,
Input, Input,
Stack, Stack,
DialogTitle,
DialogContent,
ModalDialog, ModalDialog,
Select, Select,
Option, Option,
@@ -24,10 +22,25 @@ import theme from '/src/theme';
import Visibility from '@mui/icons-material/Visibility'; import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff'; 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 [showPassword, setShowPassword] = useState(false);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [showError, setShowError] = useState(false);
useEffect(() => { useEffect(() => {
if (!isHidden && hostConfig) { if (!isHidden && hostConfig) {
@@ -37,54 +50,84 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
ip: hostConfig.ip || '', ip: hostConfig.ip || '',
user: hostConfig.user || '', user: hostConfig.user || '',
password: hostConfig.password || '', password: hostConfig.password || '',
rsaKey: hostConfig.rsaKey || '', sshKey: hostConfig.sshKey || '',
keyType: hostConfig.keyType || '',
port: hostConfig.port || 22, port: hostConfig.port || 22,
authMethod: hostConfig.password ? 'password' : hostConfig.rsaKey ? 'rsaKey' : 'Select Auth', authMethod: hostConfig.password ? 'password' : hostConfig.sshKey ? 'key' : 'Select Auth',
rememberHost: true, rememberHost: true,
storePassword: !!(hostConfig.password || hostConfig.rsaKey), storePassword: !!(hostConfig.password || hostConfig.sshKey),
}); });
} }
}, [isHidden, hostConfig]); }, [isHidden, hostConfig]);
const handleFileChange = (e) => { const handleFileChange = (e) => {
const file = e.target.files[0]; 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(); const reader = new FileReader();
reader.onload = (evt) => { 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); reader.readAsText(file);
} else { } 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) => { const handleAuthChange = (newMethod) => {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
authMethod: newMethod authMethod: newMethod,
})); password: "",
}; sshKey: "",
keyType: "",
const handleStorePasswordChange = (checked) => {
setForm((prev) => ({
...prev,
storePassword: Boolean(checked),
password: checked ? prev.password : "",
rsaKey: checked ? prev.rsaKey : "",
authMethod: checked ? prev.authMethod : "Select Auth"
})); }));
}; };
const isFormValid = () => { const 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; if (!ip?.trim() || !user?.trim() || !port) return false;
const portNum = Number(port); const portNum = Number(port);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false; if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false;
if (Boolean(storePassword) && authMethod === 'password' && !password?.trim()) return false; if (form.storePassword) {
if (Boolean(storePassword) && authMethod === 'rsaKey' && !rsaKey && !hostConfig?.rsaKey) return false; if (authMethod === 'Select Auth') return false;
if (Boolean(storePassword) && authMethod === 'Select Auth') return false; if (authMethod === 'password' && !password?.trim()) return false;
if (authMethod === 'key' && !sshKey?.trim()) return false;
}
return true; return true;
}; };
@@ -95,15 +138,46 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
setIsLoading(true); setIsLoading(true);
try { 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, name: form.name || form.ip,
folder: form.folder, folder: form.folder,
ip: form.ip, ip: form.ip,
user: form.user, user: form.user,
password: form.authMethod === 'password' ? form.password : undefined,
rsaKey: form.authMethod === 'rsaKey' ? form.rsaKey : undefined,
port: String(form.port), 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -115,11 +189,9 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
open={!isHidden} open={!isHidden}
onClose={() => !isLoading && setIsEditHostHidden(true)} onClose={() => !isLoading && setIsEditHostHidden(true)}
sx={{ sx={{
position: 'fixed',
inset: 0,
display: 'flex', display: 'flex',
alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center',
backdropFilter: 'blur(5px)', backdropFilter: 'blur(5px)',
backgroundColor: 'rgba(0, 0, 0, 0.2)', backgroundColor: 'rgba(0, 0, 0, 0.2)',
}} }}
@@ -131,7 +203,7 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
backgroundColor: theme.palette.general.tertiary, backgroundColor: theme.palette.general.tertiary,
borderColor: theme.palette.general.secondary, borderColor: theme.palette.general.secondary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
padding: 3, padding: 0,
borderRadius: 10, borderRadius: 10,
maxWidth: '500px', maxWidth: '500px',
width: '100%', width: '100%',
@@ -141,69 +213,78 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
mx: 2, mx: 2,
}} }}
> >
<DialogTitle sx={{ mb: 2 }}>Edit Host</DialogTitle> {showError && (
<DialogContent> <div style={{
<form onSubmit={handleSubmit}> backgroundColor: "#c53030",
color: "white",
padding: "10px",
textAlign: "center",
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px"
}}>
{errorMessage}
</div>
)}
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={(e, val) => setActiveTab(val)} onChange={(e, val) => setActiveTab(val)}
sx={{ sx={{
backgroundColor: theme.palette.general.disabled,
borderRadius: '8px',
padding: '8px',
marginBottom: '16px',
width: '100%', width: '100%',
mb: 0,
backgroundColor: theme.palette.general.tertiary,
}} }}
> >
<TabList <TabList
sx={{ sx={{
width: '100%', width: '100%',
gap: 0, gap: 0,
mb: 2, borderTopLeftRadius: 10,
borderTopRightRadius: 10,
backgroundColor: theme.palette.general.primary,
'& button': { '& button': {
flex: 1, flex: 1,
bgcolor: 'transparent', bgcolor: 'transparent',
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
'&:hover': { '&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.1)', bgcolor: theme.palette.general.disabled,
}, },
'&.Mui-selected': { '&.Mui-selected': {
bgcolor: theme.palette.general.primary, bgcolor: theme.palette.general.tertiary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
'&:hover': { '&:hover': {
bgcolor: theme.palette.general.primary, bgcolor: theme.palette.general.tertiary,
}, },
}, },
}, },
}} }}
> >
<Tab>Basic Info</Tab> <Tab sx={{ flex: 1 }}>Basic Info</Tab>
<Tab>Connection</Tab> <Tab sx={{ flex: 1 }}>Connection</Tab>
<Tab>Authentication</Tab> <Tab sx={{ flex: 1 }}>Authentication</Tab>
</TabList> </TabList>
<div style={{ padding: '24px', backgroundColor: theme.palette.general.tertiary }}>
<TabPanel value={0}> <TabPanel value={0}>
<Stack spacing={2}> <Stack spacing={2}>
<FormControl> <FormControl>
<FormLabel>Host Name</FormLabel> <FormLabel>Host Name</FormLabel>
<Input <Input
value={form.name} value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) => setForm({ ...form, name: e.target.value })}
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary color: theme.palette.text.primary,
}} }}
/> />
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel>Folder</FormLabel> <FormLabel>Folder</FormLabel>
<Input <Input
value={form.folder} value={form.folder || ''}
onChange={(e) => setForm((prev) => ({ ...prev, folder: e.target.value }))} onChange={(e) => setForm({ ...form, folder: e.target.value })}
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary color: theme.palette.text.primary,
}} }}
/> />
</FormControl> </FormControl>
@@ -216,35 +297,38 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
<FormLabel>Host IP</FormLabel> <FormLabel>Host IP</FormLabel>
<Input <Input
value={form.ip} value={form.ip}
onChange={(e) => setForm((prev) => ({ ...prev, ip: e.target.value }))} onChange={(e) => setForm({ ...form, ip: e.target.value })}
required
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary color: theme.palette.text.primary,
}}
/>
</FormControl>
<FormControl error={!form.user}>
<FormLabel>Host User</FormLabel>
<Input
value={form.user}
onChange={(e) => setForm({ ...form, user: e.target.value })}
required
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary,
}} }}
/> />
</FormControl> </FormControl>
<FormControl error={form.port < 1 || form.port > 65535}> <FormControl error={form.port < 1 || form.port > 65535}>
<FormLabel>Host Port</FormLabel> <FormLabel>Host Port</FormLabel>
<Input <Input
type="number" type="number"
value={form.port} value={form.port}
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))} onChange={(e) => setForm({ ...form, port: e.target.value })}
min={1}
max={65535}
required
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary color: theme.palette.text.primary,
}}
/>
</FormControl>
<FormControl error={!form.user}>
<FormLabel>Host User</FormLabel>
<Input
value={form.user}
onChange={(e) => setForm((prev) => ({ ...prev, user: e.target.value }))}
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary
}} }}
/> />
</FormControl> </FormControl>
@@ -253,22 +337,9 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
<TabPanel value={2}> <TabPanel value={2}>
<Stack spacing={2}> <Stack spacing={2}>
<FormControl>
<FormLabel>Store Password</FormLabel>
<Checkbox
checked={form.storePassword}
onChange={(e) => handleStorePasswordChange(e.target.checked)}
sx={{
color: theme.palette.text.primary,
'&.Mui-checked': {
color: theme.palette.text.primary
}
}}
/>
</FormControl>
{form.storePassword && ( {form.storePassword && (
<FormControl error={form.authMethod === 'Select Auth'}> <>
<FormControl error={!form.authMethod || form.authMethod === 'Select Auth'}>
<FormLabel>Authentication Method</FormLabel> <FormLabel>Authentication Method</FormLabel>
<Select <Select
value={form.authMethod} value={form.authMethod}
@@ -280,19 +351,18 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
> >
<Option value="Select Auth" disabled>Select Auth</Option> <Option value="Select Auth" disabled>Select Auth</Option>
<Option value="password">Password</Option> <Option value="password">Password</Option>
<Option value="rsaKey">Public Key</Option> <Option value="key">SSH Key</Option>
</Select> </Select>
</FormControl> </FormControl>
)}
{form.authMethod === 'password' && form.storePassword && ( {form.authMethod === 'password' && (
<FormControl error={!form.password}> <FormControl error={!form.password}>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<Input <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={form.password} value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))} onChange={(e) => setForm({ ...form, password: e.target.value })}
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
@@ -312,9 +382,10 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
</FormControl> </FormControl>
)} )}
{form.authMethod === 'rsaKey' && form.storePassword && ( {form.authMethod === 'key' && (
<FormControl error={!form.rsaKey && !hostConfig?.rsaKey}> <Stack spacing={2}>
<FormLabel>Public Key</FormLabel> <FormControl error={!form.sshKey}>
<FormLabel>SSH Key</FormLabel>
<Button <Button
component="label" component="label"
sx={{ sx={{
@@ -330,14 +401,14 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
}, },
}} }}
> >
{form.rsaKey ? 'Change Public Key File' : 'Upload Public Key File'} {form.sshKey ? `Change ${form.keyType || 'SSH'} Key File` : 'Upload SSH Key File'}
<Input <Input
type="file" type="file"
onChange={handleFileChange} onChange={handleFileChange}
sx={{ display: 'none' }} sx={{ display: 'none' }}
/> />
</Button> </Button>
{hostConfig?.rsaKey && !form.rsaKey && ( {hostConfig?.sshKey && !form.sshKey && (
<FormLabel <FormLabel
sx={{ sx={{
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
@@ -347,37 +418,59 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
textAlign: 'center' textAlign: 'center'
}} }}
> >
Existing key detected. Upload to replace. Existing {hostConfig.keyType || 'SSH'} key detected. Upload to replace.
</FormLabel> </FormLabel>
)} )}
</FormControl> </FormControl>
</Stack>
)} )}
</>
)}
<FormControl>
<FormLabel>Store Password</FormLabel>
<Checkbox
checked={Boolean(form.storePassword)}
onChange={(e) => 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,
},
}}
/>
</FormControl>
</Stack> </Stack>
</TabPanel> </TabPanel>
</Tabs> </div>
<Button <Button
type="submit" onClick={handleSubmit}
disabled={!isFormValid() || isLoading} disabled={isLoading || !isFormValid()}
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
'&:hover': { '&:hover': {
backgroundColor: theme.palette.general.disabled backgroundColor: theme.palette.general.disabled,
}, },
'&:disabled': { '&:disabled': {
backgroundColor: 'rgba(255, 255, 255, 0.1)', backgroundColor: 'rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.3)', color: 'rgba(255, 255, 255, 0.3)',
}, },
marginTop: 3, marginTop: 1,
width: '100%', width: '100%',
height: '40px', height: '40px',
}} }}
> >
{isLoading ? "Saving..." : "Save Changes"} {isLoading ? "Saving changes..." : "Save changes"}
</Button> </Button>
</form> </Tabs>
</DialogContent>
</ModalDialog> </ModalDialog>
</Modal> </Modal>
</CssVarsProvider> </CssVarsProvider>
@@ -386,11 +479,9 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
EditHostModal.propTypes = { EditHostModal.propTypes = {
isHidden: PropTypes.bool.isRequired, isHidden: PropTypes.bool.isRequired,
form: PropTypes.object.isRequired, hostConfig: PropTypes.object,
setForm: PropTypes.func.isRequired,
handleEditHost: PropTypes.func.isRequired,
setIsEditHostHidden: PropTypes.func.isRequired, setIsEditHostHidden: PropTypes.func.isRequired,
hostConfig: PropTypes.object handleEditHost: PropTypes.func.isRequired
}; };
export default EditHostModal; export default EditHostModal;

View File

@@ -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 (
<CssVarsProvider theme={theme}>
<Modal open={!isHidden} onClose={() => {}}>
<ModalDialog
layout="center"
sx={{
backgroundColor: theme.palette.general.tertiary,
borderColor: theme.palette.general.secondary,
color: theme.palette.text.primary,
padding: 3,
borderRadius: 10,
width: "auto",
maxWidth: "90vw",
minWidth: "fit-content",
overflow: "hidden",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<DialogTitle>Login</DialogTitle>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault();
if (isFormValid()) handleLogin();
}}
>
<Stack spacing={2} sx={{ width: "100%", maxWidth: "100%", overflow: "hidden" }}>
<FormControl>
<FormLabel>Username</FormLabel>
<Input
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary,
}}
/>
</FormControl>
<FormControl>
<FormLabel>Password</FormLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Input
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary,
flex: 1,
}}
/>
<IconButton
onClick={() => setShowPassword(!showPassword)}
sx={{
color: theme.palette.text.primary,
marginLeft: 1,
}}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</div>
</FormControl>
<Button
type="submit"
disabled={!isFormValid()}
sx={{
backgroundColor: theme.palette.general.primary,
'&:hover': {
backgroundColor: theme.palette.general.disabled,
},
}}
>
Login
</Button>
<Button
onClick={() => {
setForm({ username: '', password: '' });
setIsCreateUserHidden(false);
setIsLoginUserHidden(true);
}}
sx={{
backgroundColor: theme.palette.general.primary,
'&:hover': {
backgroundColor: theme.palette.general.disabled,
},
}}
>
Create User
</Button>
<Button
onClick={handleGuestLogin}
sx={{
backgroundColor: theme.palette.general.primary,
'&:hover': {
backgroundColor: theme.palette.general.disabled,
},
}}
>
Login as Guest
</Button>
</Stack>
</form>
</DialogContent>
</ModalDialog>
</Modal>
</CssVarsProvider>
);
};
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;

View File

@@ -15,7 +15,7 @@ import {
Option, Option,
} from '@mui/joy'; } from '@mui/joy';
import theme from '/src/theme'; import theme from '/src/theme';
import { useState, useEffect } from 'react'; import {useEffect, useState} from 'react';
import Visibility from '@mui/icons-material/Visibility'; import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff'; import VisibilityOff from '@mui/icons-material/VisibilityOff';
@@ -26,23 +26,91 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han
if (!form.authMethod) { if (!form.authMethod) {
setForm(prev => ({ setForm(prev => ({
...prev, ...prev,
authMethod: 'Select Auth' authMethod: 'Select Auth',
password: '',
sshKey: '',
keyType: '',
})); }));
} }
}, []); }, []);
const isFormValid = () => { const isFormValid = () => {
if (!form.authMethod || form.authMethod === 'Select Auth') return false; 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; if (form.authMethod === 'password' && !form.password) return false;
return true; return true;
}; };
const handleSubmit = (event) => { const handleSubmit = (e) => {
event.preventDefault(); e.preventDefault();
e.stopPropagation();
try {
if(isFormValid()) { if(isFormValid()) {
handleAuthSubmit(form); const formData = {
setForm({ authMethod: 'Select Auth', password: '', rsaKey: '' }); 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
<FormLabel>Authentication Method</FormLabel> <FormLabel>Authentication Method</FormLabel>
<Select <Select
value={form.authMethod || 'Select Auth'} value={form.authMethod || 'Select Auth'}
onChange={(e, val) => setForm(prev => ({ ...prev, authMethod: val, password: '', rsaKey: '' }))} onChange={(e, val) => setForm(prev => ({
...prev,
authMethod: val,
password: '',
sshKey: '',
keyType: '',
}))}
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
@@ -93,7 +167,7 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han
> >
<Option value="Select Auth" disabled>Select Auth</Option> <Option value="Select Auth" disabled>Select Auth</Option>
<Option value="password">Password</Option> <Option value="password">Password</Option>
<Option value="rsaKey">Public Key</Option> <Option value="sshKey">SSH Key</Option >
</Select> </Select>
</FormControl> </FormControl>
@@ -102,9 +176,9 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<Input <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
value={form.password || ''} value={form.password || ''}
onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))} onChange={(e) => setForm({...form, password: e.target.value})}
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
@@ -115,7 +189,10 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
sx={{ sx={{
color: theme.palette.text.primary, color: theme.palette.text.primary,
marginLeft: 1 marginLeft: 1,
'&:disabled': {
opacity: 0.5,
},
}} }}
> >
{showPassword ? <VisibilityOff /> : <Visibility />} {showPassword ? <VisibilityOff /> : <Visibility />}
@@ -124,9 +201,10 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han
</FormControl> </FormControl>
)} )}
{form.authMethod === 'rsaKey' && ( {form.authMethod === 'sshKey' && (
<FormControl error={!form.rsaKey}> <Stack spacing={2}>
<FormLabel>Public Key</FormLabel> <FormControl error={!form.sshKey}>
<FormLabel>SSH Key</FormLabel>
<Button <Button
component="label" component="label"
sx={{ sx={{
@@ -142,23 +220,15 @@ const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, han
}, },
}} }}
> >
{form.rsaKey ? 'Change Public Key File' : 'Upload Public Key File'} {form.sshKey ? `Change ${form.keyType || 'SSH'} Key File` : 'Upload SSH Key File'}
<Input <Input
type="file" type="file"
onChange={(e) => { onChange={handleFileChange}
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
setForm({ ...form, rsaKey: event.target.result });
};
reader.readAsText(file);
}
}}
sx={{ display: 'none' }} sx={{ display: 'none' }}
/> />
</Button> </Button>
</FormControl> </FormControl>
</Stack>
)} )}
<Button <Button

View File

@@ -71,6 +71,10 @@ export default function ProfileModal({
> >
Delete Account Delete Account
</Button> </Button>
<div className="text-center text-xs text-gray-400">
v0.2.1
</div>
</div> </div>
</div> </div>
</Modal> </Modal>

5
src/other/eventBus.jsx Normal file
View File

@@ -0,0 +1,5 @@
import mitt from "mitt";
const eventBus = mitt();
export default eventBus;