Dev 2.0 (#23)
* Added user system with database features. This is fairly experimental and does not include dockerfile to automatically generate a mongodb. This should be in future commits along with ability to save hosts branching off this database feature. * Updated README, fixed a few bugs with user creation, and added docker support to run MongoDB (needs testing) * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in installing MongoDB * Changes to Dockerfile to fix error in connecting to sockets * Update README.md * Changes to connection system to support docker * Changes to connection system to support docker * Changes to connection system to support docker * Changes to connection system to support docker * Save hosts to tabs (very early version, not that many issues not just not very feature rich and has a poor UI that will be improved with more features) * Updated launchpad UI to be expandable in the future. Updated UI for the hosts to be able to easily configure them. They stil need organizational system (folders, etc.) * Better encryption for everything, new session login, rewrote a lot of database code changing its storage methods. Prepared for release of 2.0. * Updated database connection method. * Updated Profile modal to show username text more clearly * Updated Profile modal to show username text more clearly * Fixed control v pasting formating. Reorganized location of scripts. Visbile password and confirm password. Guest login. OpenSSH key authentication. Optional to remember password. Serach for host viewer. * Waits for user to be able to log in. Improved UI for profile, edit and add host, and added organizational features to the host app (Folders, search, etc.) * Updated various names for rsa keys to public keys, fixes ssh not connecting, better timing for editing host. * Added ability to share hosts. Fixed up overall UI errors in console and cleaned up code for release. * Fix GitHub build errors * Attempt #1 to auto compile MongoDB into the build to exclude it from the compose. * Attempt #2 to auto compile MongoDB into the build to exclude it from the compose. * Attempt #3 to auto compile MongoDB into the build to exclude it from the compose. * Attempt #3 to auto compile MongoDB into the build to exclude it from the compose.
This commit was merged in pull request #23.
This commit is contained in:
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
|||||||
- name: Notify via ntfy
|
- name: Notify via ntfy
|
||||||
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.karmaashomepage.online/termix-build
|
https://ntfy.karmaa.site/termix-build
|
||||||
|
|
||||||
- name: Delete all untagged image versions
|
- name: Delete all untagged image versions
|
||||||
uses: quartx-analytics/ghcr-cleaner@v1
|
uses: quartx-analytics/ghcr-cleaner@v1
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -158,3 +158,7 @@ typings/
|
|||||||
|
|
||||||
# .local
|
# .local
|
||||||
.local/
|
.local/
|
||||||
|
/docker/docker-compose.yml
|
||||||
|
/src/data/
|
||||||
|
/docker/mongodb/
|
||||||
|
/docker/docker-compose.yml
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -8,8 +8,11 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#)
|
[](#)
|
||||||
[](#)
|
[](#)
|
||||||
[](#)
|
[](#)
|
||||||
[](#)
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -29,26 +32,31 @@ Termix is an open-source forever free self-hosted SSH (other protocols planned,
|
|||||||
# Features
|
# Features
|
||||||
- SSH
|
- SSH
|
||||||
- Split Screen (Up to 4) & Tab System
|
- Split Screen (Up to 4) & Tab System
|
||||||
|
- User Authentication
|
||||||
|
- Save Hosts (and easily view, connect, and manage them)
|
||||||
|
|
||||||
# Planned Features
|
# Planned Features
|
||||||
- Database to Store Connection Details
|
|
||||||
- VNC
|
- VNC
|
||||||
- RDP
|
- RDP
|
||||||
- SFTP (build in file transfer)
|
- SFTP (build in file transfer)
|
||||||
- ChatGPT/Ollama Integration (for commands)
|
- ChatGPT/Ollama Integration (for commands)
|
||||||
- Login Screen
|
|
||||||
- User Management
|
|
||||||
- Apps (like notes, AI, etc)
|
- Apps (like notes, AI, etc)
|
||||||
|
- Terminal Themes
|
||||||
|
- User Management (roles, permissions, etc.)
|
||||||
|
- SSH Tunneling
|
||||||
|
- More Authentication Methods
|
||||||
|
- More Security Features (like 2FA, etc.)
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
Visit the Termix [Wiki](https://github.com/LukeGus/Termix/wiki) for information on how to install Termix. You can also use these links to go directly to guide. [Docker](https://github.com/LukeGus/Termix/wiki/Docker) or [Manual](https://github.com/LukeGus/Termix/wiki/Manual).
|
Visit the Termix [Wiki](https://github.com/LukeGus/Termix/wiki) for information on how to install Termix. You can also use these links to go directly to guide. [Docker](https://github.com/LukeGus/Termix/wiki/Docker) or [Manual](https://github.com/LukeGus/Termix/wiki/Manual).
|
||||||
|
|
||||||
# Support
|
# Support
|
||||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo. If you would like to support me financially, you can on [Paypal](https://paypal.me/LukeGustafson803)
|
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo. If you would like to support me financially, you can on [Paypal](https://paypal.me/LukeGustafson803).
|
||||||
|
|
||||||
# Show-off
|
# Show-off
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
# License
|
# License
|
||||||
Distributed under the MIT license. See LICENSE for more information.
|
Distributed under the MIT license. See LICENSE for more information.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build frontend
|
# Stage 1: Build frontend
|
||||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-builder
|
FROM --platform=$BUILDPLATFORM node:18 AS frontend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
@@ -7,31 +7,53 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build backend
|
# Stage 2: Build backend
|
||||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS backend-builder
|
FROM --platform=$BUILDPLATFORM node:18 AS backend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY src/backend/ ./src/backend/
|
COPY src/backend/ ./src/backend/
|
||||||
|
|
||||||
# Stage 3: Final production image
|
# Stage 3: Final production image
|
||||||
FROM node:18-alpine
|
FROM mongo:5
|
||||||
RUN apk add --no-cache nginx
|
# Install Node.js
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
nginx \
|
||||||
|
python3 \
|
||||||
|
build-essential \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Configure nginx
|
# Configure nginx
|
||||||
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
|
||||||
|
|
||||||
# Copy backend
|
# Setup backend
|
||||||
COPY --from=backend-builder /app/node_modules ./node_modules
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
COPY --from=backend-builder /app/src/backend ./src/backend
|
COPY --from=backend-builder /app/src/backend ./src/backend
|
||||||
|
|
||||||
# Create separate directories for nginx and node
|
# Create directories for MongoDB and nginx
|
||||||
RUN mkdir -p /var/log/nginx && \
|
RUN mkdir -p /data/db && \
|
||||||
|
mkdir -p /var/log/nginx && \
|
||||||
mkdir -p /var/lib/nginx && \
|
mkdir -p /var/lib/nginx && \
|
||||||
chown -R nginx:nginx /var/log/nginx /var/lib/nginx
|
mkdir -p /var/log/mongodb && \
|
||||||
|
chown -R mongodb:mongodb /data/db /var/log/mongodb && \
|
||||||
|
chown -R www-data:www-data /var/log/nginx /var/lib/nginx
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV MONGO_URL=mongodb://localhost:27017/termix \
|
||||||
|
MONGODB_DATA_DIR=/data/db \
|
||||||
|
MONGODB_LOG_DIR=/var/log/mongodb
|
||||||
|
|
||||||
|
# Create volume for MongoDB data
|
||||||
|
VOLUME ["/data/db"]
|
||||||
|
|
||||||
# Expose ports
|
# Expose ports
|
||||||
EXPOSE 8080 8081
|
EXPOSE 8080 8081 8082 27017
|
||||||
|
|
||||||
# Use a entrypoint script to run all services
|
# Use a entrypoint script to run all services
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
# Start NGINX in background
|
# Start MongoDB
|
||||||
nginx -g "daemon off;" &
|
echo "Starting MongoDB..."
|
||||||
|
mongod --fork --dbpath $MONGODB_DATA_DIR --logpath $MONGODB_LOG_DIR/mongodb.log
|
||||||
|
|
||||||
# Start Node.js backend
|
# Wait for MongoDB to be ready
|
||||||
node src/backend/server.cjs
|
echo "Waiting for MongoDB to start..."
|
||||||
|
until mongosh --eval "print(\"waited for connection\")" > /dev/null 2>&1; do
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo "MongoDB has started"
|
||||||
|
|
||||||
# Keep container running
|
# Start nginx
|
||||||
wait
|
echo "Starting nginx..."
|
||||||
|
nginx
|
||||||
|
|
||||||
|
# Change to app directory
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
# Start the SSH service
|
||||||
|
echo "Starting SSH service..."
|
||||||
|
node src/backend/ssh.cjs &
|
||||||
|
|
||||||
|
# Start the database service
|
||||||
|
echo "Starting database service..."
|
||||||
|
node src/backend/database.cjs &
|
||||||
|
|
||||||
|
# Keep the container running and show MongoDB logs
|
||||||
|
echo "All services started. Tailing MongoDB logs..."
|
||||||
|
tail -f $MONGODB_LOG_DIR/mongodb.log
|
||||||
@@ -19,18 +19,32 @@ http {
|
|||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy IO requests
|
# Proxy SSH socket requests
|
||||||
location /socket.io/ {
|
location /ssh.io/ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection "Upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
# Timeout settings
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_read_timeout 86400s;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_send_timeout 86400s;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy MongoDB socket requests
|
||||||
|
location /database.io/ {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Error pages
|
# Error pages
|
||||||
|
|||||||
1011
package-lock.json
generated
1011
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@fontsource/inter": "^5.1.1",
|
"@fontsource/inter": "^5.1.1",
|
||||||
|
"@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.8",
|
||||||
"@tiptap/extension-link": "^2.11.5",
|
"@tiptap/extension-link": "^2.11.5",
|
||||||
@@ -21,12 +22,16 @@
|
|||||||
"@tiptap/starter-kit": "^2.11.5",
|
"@tiptap/starter-kit": "^2.11.5",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"crypto": "^1.0.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"embla-carousel-react": "^7.1.0",
|
"embla-carousel-react": "^7.1.0",
|
||||||
"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",
|
||||||
|
"mongoose": "^8.12.1",
|
||||||
"node-ssh": "^13.2.0",
|
"node-ssh": "^13.2.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 454 KiB |
BIN
repo-images/DemoImage2.png
Normal file
BIN
repo-images/DemoImage2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
@@ -1,200 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { CssVarsProvider } from '@mui/joy/styles';
|
|
||||||
import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog, Select, Option } from '@mui/joy';
|
|
||||||
import theme from './theme';
|
|
||||||
|
|
||||||
const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidden }) => {
|
|
||||||
const handleFileChange = (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
if (file.name.endsWith('.rsa') || file.name.endsWith('.key') || file.name.endsWith('.pem') || file.name.endsWith('.der') || file.name.endsWith('.p8') || file.name.endsWith('.ssh')) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
setForm({ ...form, rsaKey: event.target.result });
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
} else {
|
|
||||||
alert("Please upload a valid RSA private key file.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFormValid = () => {
|
|
||||||
if (form.authMethod === 'Select Auth') return false;
|
|
||||||
if (!form.ip || !form.user || !form.port) return false;
|
|
||||||
if (form.authMethod === 'rsaKey' && !form.rsaKey) return false;
|
|
||||||
if (form.authMethod === 'password' && !form.password) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CssVarsProvider theme={theme}>
|
|
||||||
<Modal open={!isHidden} onClose={() => setIsAddHostHidden(true)}>
|
|
||||||
<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>Add Host</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (isFormValid()) handleAddHost();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack spacing={2} sx={{ width: "100%", maxWidth: "100%", overflow: "hidden" }}>
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel>Host Name</FormLabel>
|
|
||||||
<Input
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.general.primary,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl error={!form.ip}>
|
|
||||||
<FormLabel>Host IP</FormLabel>
|
|
||||||
<Input
|
|
||||||
value={form.ip}
|
|
||||||
onChange={(e) => setForm({ ...form, ip: e.target.value })}
|
|
||||||
required
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.general.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 error={!form.authMethod || form.authMethod === 'Select Auth'}>
|
|
||||||
<FormLabel>Authentication Method</FormLabel>
|
|
||||||
<Select
|
|
||||||
value={form.authMethod || 'Select Auth'}
|
|
||||||
onChange={(e, newValue) => setForm({ ...form, authMethod: newValue })}
|
|
||||||
required
|
|
||||||
sx={{
|
|
||||||
backgroundColor: !form.authMethod || form.authMethod === 'Select Auth' ? theme.palette.general.tertiary : theme.palette.general.primary,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: theme.palette.general.disabled,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Option value="Select Auth" disabled>
|
|
||||||
Select Auth
|
|
||||||
</Option>
|
|
||||||
<Option value="password">Password</Option>
|
|
||||||
<Option value="rsaKey">RSA Key</Option>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
{form.authMethod === 'password' && (
|
|
||||||
<FormControl error={!form.password}>
|
|
||||||
<FormLabel>Host Password</FormLabel>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
||||||
required
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.general.primary,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
{form.authMethod === 'rsaKey' && (
|
|
||||||
<FormControl error={!form.rsaKey}>
|
|
||||||
<FormLabel>RSA Key</FormLabel>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
required
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.general.primary,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
padding: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
width: '100%',
|
|
||||||
minWidth: 'auto',
|
|
||||||
minHeight: 'auto',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
<FormControl error={form.port < 1 || form.port > 65535}>
|
|
||||||
<FormLabel>Host Port</FormLabel>
|
|
||||||
<Input
|
|
||||||
value={form.port}
|
|
||||||
onChange={(e) => setForm({ ...form, port: e.target.value })}
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
required
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.general.primary,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!isFormValid()}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.general.primary,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: theme.palette.general.disabled,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Host
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</ModalDialog>
|
|
||||||
</Modal>
|
|
||||||
</CssVarsProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
AddHostModal.propTypes = {
|
|
||||||
isHidden: PropTypes.bool.isRequired,
|
|
||||||
form: PropTypes.shape({
|
|
||||||
name: PropTypes.string,
|
|
||||||
ip: PropTypes.string.isRequired,
|
|
||||||
user: PropTypes.string.isRequired,
|
|
||||||
password: PropTypes.string,
|
|
||||||
rsaKey: PropTypes.string,
|
|
||||||
port: PropTypes.number.isRequired,
|
|
||||||
authMethod: PropTypes.string.isRequired,
|
|
||||||
}).isRequired,
|
|
||||||
setForm: PropTypes.func.isRequired,
|
|
||||||
handleAddHost: PropTypes.func.isRequired,
|
|
||||||
setIsAddHostHidden: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddHostModal;
|
|
||||||
590
src/App.jsx
590
src/App.jsx
@@ -1,30 +1,75 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { NewTerminal } from "./Terminal.jsx";
|
import { NewTerminal } from "./apps/ssh/Terminal.jsx";
|
||||||
import AddHostModal from "./AddHostModal.jsx";
|
import { User } from "./apps/user/User.jsx";
|
||||||
|
import AddHostModal from "./modals/AddHostModal.jsx";
|
||||||
|
import LoginUserModal from "./modals/LoginUserModal.jsx";
|
||||||
import { Button } from "@mui/joy";
|
import { Button } from "@mui/joy";
|
||||||
import { CssVarsProvider } from "@mui/joy";
|
import { CssVarsProvider } from "@mui/joy";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import TabList from "./TabList.jsx";
|
import TabList from "./ui/TabList.jsx";
|
||||||
import Launchpad from "./Launchpad.jsx";
|
import Launchpad from "./apps/Launchpad.jsx";
|
||||||
import { Debounce } from './Utils';
|
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 CreateUserModal from "./modals/CreateUserModal.jsx";
|
||||||
|
import ProfileModal from "./modals/ProfileModal.jsx";
|
||||||
|
import ErrorModal from "./modals/ErrorModal.jsx";
|
||||||
|
import EditHostModal from "./modals/EditHostModal.jsx";
|
||||||
|
import NoAuthenticationModal from "./modals/NoAuthenticationModal.jsx";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isAddHostHidden, setIsAddHostHidden] = useState(true);
|
const [isAddHostHidden, setIsAddHostHidden] = useState(true);
|
||||||
|
const [isLoginUserHidden, setIsLoginUserHidden] = useState(true);
|
||||||
|
const [isCreateUserHidden, setIsCreateUserHidden] = useState(true);
|
||||||
|
const [isProfileHidden, setIsProfileHidden] = useState(true);
|
||||||
|
const [isErrorHidden, setIsErrorHidden] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [terminals, setTerminals] = useState([]);
|
const [terminals, setTerminals] = useState([]);
|
||||||
|
const userRef = useRef(null);
|
||||||
const [activeTab, setActiveTab] = useState(null);
|
const [activeTab, setActiveTab] = useState(null);
|
||||||
const [nextId, setNextId] = useState(1);
|
const [nextId, setNextId] = useState(1);
|
||||||
const [form, setForm] = useState({
|
const [addHostForm, setAddHostForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
|
folder: "",
|
||||||
ip: "",
|
ip: "",
|
||||||
user: "",
|
user: "",
|
||||||
password: "",
|
password: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
authMethod: "Select Auth",
|
authMethod: "Select Auth",
|
||||||
|
rememberHost: false,
|
||||||
|
storePassword: true,
|
||||||
|
});
|
||||||
|
const [editHostForm, setEditHostForm] = useState({
|
||||||
|
name: "",
|
||||||
|
folder: "",
|
||||||
|
ip: "",
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
port: 22,
|
||||||
|
authMethod: "Select Auth",
|
||||||
|
rememberHost: true,
|
||||||
|
storePassword: true,
|
||||||
|
});
|
||||||
|
const [isNoAuthHidden, setIsNoAuthHidden] = useState(true);
|
||||||
|
const [authForm, setAuthForm] = useState({
|
||||||
|
password: "",
|
||||||
|
rsaKey: "",
|
||||||
|
});
|
||||||
|
const [loginUserForm, setLoginUserForm] = useState({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [createUserForm, setCreateUserForm] = useState({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
});
|
});
|
||||||
const [isLaunchpadOpen, setIsLaunchpadOpen] = useState(false);
|
const [isLaunchpadOpen, setIsLaunchpadOpen] = useState(false);
|
||||||
const [splitTabIds, setSplitTabIds] = useState([]);
|
const [splitTabIds, setSplitTabIds] = useState([]);
|
||||||
|
const [isEditHostHidden, setIsEditHostHidden] = useState(true);
|
||||||
|
const [currentHostConfig, setCurrentHostConfig] = useState(null);
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(true);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
@@ -81,27 +126,347 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, [splitTabIds]);
|
}, [splitTabIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionToken = localStorage.getItem('sessionToken');
|
||||||
|
let isComponentMounted = true;
|
||||||
|
let isLoginInProgress = false;
|
||||||
|
|
||||||
|
if (userRef.current?.getUser()) {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setIsLoginUserHidden(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setIsLoginUserHidden(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
let loginAttempts = 0;
|
||||||
|
const maxAttempts = 50;
|
||||||
|
let attemptLoginInterval;
|
||||||
|
|
||||||
|
const loginTimeout = setTimeout(() => {
|
||||||
|
if (isComponentMounted) {
|
||||||
|
clearInterval(attemptLoginInterval);
|
||||||
|
if (!userRef.current?.getUser()) {
|
||||||
|
localStorage.removeItem('sessionToken');
|
||||||
|
setIsLoginUserHidden(false);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setErrorMessage('Login timed out. Please try again.');
|
||||||
|
setIsErrorHidden(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const attemptLogin = () => {
|
||||||
|
if (!isComponentMounted || isLoginInProgress) return;
|
||||||
|
|
||||||
|
if (loginAttempts >= maxAttempts || userRef.current?.getUser()) {
|
||||||
|
clearTimeout(loginTimeout);
|
||||||
|
clearInterval(attemptLoginInterval);
|
||||||
|
|
||||||
|
if (!userRef.current?.getUser()) {
|
||||||
|
localStorage.removeItem('sessionToken');
|
||||||
|
setIsLoginUserHidden(false);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setErrorMessage('Login timed out. Please try again.');
|
||||||
|
setIsErrorHidden(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRef.current) {
|
||||||
|
isLoginInProgress = true;
|
||||||
|
userRef.current.loginUser({
|
||||||
|
sessionToken,
|
||||||
|
onSuccess: () => {
|
||||||
|
if (isComponentMounted) {
|
||||||
|
clearTimeout(loginTimeout);
|
||||||
|
clearInterval(attemptLoginInterval);
|
||||||
|
setIsLoginUserHidden(true);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setIsErrorHidden(true);
|
||||||
|
}
|
||||||
|
isLoginInProgress = false;
|
||||||
|
},
|
||||||
|
onFailure: (error) => {
|
||||||
|
if (isComponentMounted) {
|
||||||
|
if (!userRef.current?.getUser()) {
|
||||||
|
clearTimeout(loginTimeout);
|
||||||
|
clearInterval(attemptLoginInterval);
|
||||||
|
localStorage.removeItem('sessionToken');
|
||||||
|
setErrorMessage(`Auto-login failed: ${error}`);
|
||||||
|
setIsErrorHidden(false);
|
||||||
|
setIsLoginUserHidden(false);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoginInProgress = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loginAttempts++;
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptLoginInterval = setInterval(attemptLogin, 100);
|
||||||
|
attemptLogin();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isComponentMounted = false;
|
||||||
|
clearTimeout(loginTimeout);
|
||||||
|
clearInterval(attemptLoginInterval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAddHost = () => {
|
const handleAddHost = () => {
|
||||||
if (form.ip && form.user && ((form.authMethod === 'password' && form.password) || (form.authMethod === 'rsaKey' && form.rsaKey)) && form.port) {
|
if (addHostForm.ip && addHostForm.user && addHostForm.port) {
|
||||||
|
if (!addHostForm.rememberHost) {
|
||||||
|
connectToHost();
|
||||||
|
setIsAddHostHidden(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addHostForm.authMethod === 'Select Auth') {
|
||||||
|
alert("Please select an authentication method.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (addHostForm.authMethod === 'password' && !addHostForm.password) {
|
||||||
|
setIsNoAuthHidden(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (addHostForm.authMethod === 'rsaKey' && !addHostForm.rsaKey) {
|
||||||
|
setIsNoAuthHidden(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectToHost();
|
||||||
|
if (!addHostForm.storePassword) {
|
||||||
|
addHostForm.password = '';
|
||||||
|
}
|
||||||
|
handleSaveHost();
|
||||||
|
setIsAddHostHidden(true);
|
||||||
|
} else {
|
||||||
|
alert("Please fill out all required fields (IP, User, Port).");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectToHost = () => {
|
||||||
|
const hostConfig = {
|
||||||
|
name: addHostForm.name || '',
|
||||||
|
folder: addHostForm.folder || '',
|
||||||
|
ip: addHostForm.ip,
|
||||||
|
user: addHostForm.user,
|
||||||
|
port: String(addHostForm.port),
|
||||||
|
password: addHostForm.rememberHost && addHostForm.authMethod === 'password' ? addHostForm.password : undefined,
|
||||||
|
rsaKey: addHostForm.rememberHost && addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
const newTerminal = {
|
const newTerminal = {
|
||||||
id: nextId,
|
id: nextId,
|
||||||
title: form.name || form.ip,
|
title: hostConfig.name || hostConfig.ip,
|
||||||
hostConfig: {
|
hostConfig,
|
||||||
ip: form.ip,
|
|
||||||
user: form.user,
|
|
||||||
password: form.authMethod === 'password' ? form.password : undefined,
|
|
||||||
rsaKey: form.authMethod === 'rsaKey' ? form.rsaKey : undefined,
|
|
||||||
port: String(form.port),
|
|
||||||
},
|
|
||||||
terminalRef: null,
|
terminalRef: null,
|
||||||
};
|
};
|
||||||
setTerminals([...terminals, newTerminal]);
|
setTerminals([...terminals, newTerminal]);
|
||||||
setActiveTab(nextId);
|
setActiveTab(nextId);
|
||||||
setNextId(nextId + 1);
|
setNextId(nextId + 1);
|
||||||
setIsAddHostHidden(true);
|
setIsAddHostHidden(true);
|
||||||
setForm({ name: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth" });
|
setAddHostForm({ name: "", folder: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth", rememberHost: false, storePassword: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAuthSubmit = (form) => {
|
||||||
|
const updatedTerminals = terminals.map((terminal) => {
|
||||||
|
if (terminal.id === activeTab) {
|
||||||
|
return {
|
||||||
|
...terminal,
|
||||||
|
hostConfig: {
|
||||||
|
...terminal.hostConfig,
|
||||||
|
password: form.password,
|
||||||
|
rsaKey: form.rsaKey
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return terminal;
|
||||||
|
});
|
||||||
|
setTerminals(updatedTerminals);
|
||||||
|
setIsNoAuthHidden(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectToHostWithConfig = (hostConfig) => {
|
||||||
|
if (!hostConfig || typeof hostConfig !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostConfig.ip || !hostConfig.user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanHostConfig = {
|
||||||
|
name: hostConfig.name || '',
|
||||||
|
folder: hostConfig.folder || '',
|
||||||
|
ip: hostConfig.ip.trim(),
|
||||||
|
user: hostConfig.user.trim(),
|
||||||
|
port: hostConfig.port || '22',
|
||||||
|
password: hostConfig.password?.trim(),
|
||||||
|
rsaKey: hostConfig.rsaKey?.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newTerminal = {
|
||||||
|
id: nextId,
|
||||||
|
title: cleanHostConfig.name || cleanHostConfig.ip,
|
||||||
|
hostConfig: cleanHostConfig,
|
||||||
|
terminalRef: null,
|
||||||
|
};
|
||||||
|
setTerminals([...terminals, newTerminal]);
|
||||||
|
setActiveTab(nextId);
|
||||||
|
setNextId(nextId + 1);
|
||||||
|
setIsLaunchpadOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveHost = () => {
|
||||||
|
let hostConfig = {
|
||||||
|
name: addHostForm.name || addHostForm.ip,
|
||||||
|
folder: addHostForm.folder,
|
||||||
|
ip: addHostForm.ip,
|
||||||
|
user: addHostForm.user,
|
||||||
|
password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined,
|
||||||
|
rsaKey: addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined,
|
||||||
|
port: String(addHostForm.port),
|
||||||
|
}
|
||||||
|
if (userRef.current) {
|
||||||
|
userRef.current.saveHost({
|
||||||
|
hostConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoginUser = ({ username, password, sessionToken, onSuccess, onFailure }) => {
|
||||||
|
if (userRef.current) {
|
||||||
|
if (sessionToken) {
|
||||||
|
userRef.current.loginUser({
|
||||||
|
sessionToken,
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsLoginUserHidden(true);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
},
|
||||||
|
onFailure: (error) => {
|
||||||
|
localStorage.removeItem('sessionToken');
|
||||||
|
setIsLoginUserHidden(false);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
if (onFailure) onFailure(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
alert("Please fill out all fields.");
|
userRef.current.loginUser({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsLoginUserHidden(true);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
},
|
||||||
|
onFailure: (error) => {
|
||||||
|
setIsLoginUserHidden(false);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
if (onFailure) onFailure(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGuestLogin = () => {
|
||||||
|
if (userRef.current) {
|
||||||
|
userRef.current.loginAsGuest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = ({ username, password, onSuccess, onFailure }) => {
|
||||||
|
if (userRef.current) {
|
||||||
|
userRef.current.createUser({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
onSuccess,
|
||||||
|
onFailure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = ({ onSuccess, onFailure }) => {
|
||||||
|
if (userRef.current) {
|
||||||
|
userRef.current.deleteUser({
|
||||||
|
onSuccess,
|
||||||
|
onFailure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoutUser = () => {
|
||||||
|
if (userRef.current) {
|
||||||
|
userRef.current.logoutUser();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUser = () => {
|
||||||
|
if (userRef.current) {
|
||||||
|
return userRef.current.getUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHosts = () => {
|
||||||
|
if (userRef.current) {
|
||||||
|
return userRef.current.getAllHosts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteHost = (hostConfig) => {
|
||||||
|
if (userRef.current) {
|
||||||
|
userRef.current.deleteHost({
|
||||||
|
hostId: hostConfig._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEditHostForm = (hostConfig) => {
|
||||||
|
if (hostConfig) {
|
||||||
|
setCurrentHostConfig(hostConfig);
|
||||||
|
setIsEditHostHidden(false);
|
||||||
|
} else {
|
||||||
|
console.error("hostConfig is null");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditHost = async (oldConfig, newConfig = null) => {
|
||||||
|
try {
|
||||||
|
if (newConfig) {
|
||||||
|
if (isEditing) return;
|
||||||
|
setIsEditing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userRef.current.editHost({
|
||||||
|
oldHostConfig: oldConfig,
|
||||||
|
newHostConfig: newConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
} finally {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsEditHostHidden(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEditHostForm(oldConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Edit failed:', error);
|
||||||
|
setErrorMessage(`Edit failed: ${error}`);
|
||||||
|
setIsErrorHidden(false);
|
||||||
|
setIsEditing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,8 +529,11 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-4">
|
||||||
{/* Launchpad Button */}
|
{/* Launchpad Button */}
|
||||||
<Button
|
<Button
|
||||||
|
disabled={isLoggingIn || !userRef.current?.getUser()}
|
||||||
onClick={() => setIsLaunchpadOpen(true)}
|
onClick={() => setIsLaunchpadOpen(true)}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: theme.palette.general.tertiary,
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
@@ -174,13 +542,20 @@ function App() {
|
|||||||
height: "52px",
|
height: "52px",
|
||||||
width: "52px",
|
width: "52px",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
opacity: (!userRef.current?.getUser() || isLoggingIn) ? 0.3 : 1,
|
||||||
|
cursor: (!userRef.current?.getUser() || isLoggingIn) ? 'not-allowed' : 'pointer',
|
||||||
|
"&:disabled": {
|
||||||
|
opacity: 0.3,
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={RocketIcon} alt="Launchpad" style={{ width: "70%", height: "70", objectFit: "contain" }} />
|
<img src={RocketIcon} alt="Launchpad" style={{ width: "70%", height: "70%", objectFit: "contain" }} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Add Host Button */}
|
{/* Add Host Button */}
|
||||||
<Button
|
<Button
|
||||||
|
disabled={isLoggingIn || !userRef.current?.getUser()}
|
||||||
onClick={() => setIsAddHostHidden(false)}
|
onClick={() => setIsAddHostHidden(false)}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: theme.palette.general.tertiary,
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
@@ -188,20 +563,60 @@ function App() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
height: "52px",
|
height: "52px",
|
||||||
width: "52px",
|
width: "52px",
|
||||||
fontSize: "3.5rem",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingTop: "2px",
|
padding: 0,
|
||||||
|
opacity: (!userRef.current?.getUser() || isLoggingIn) ? 0.3 : 1,
|
||||||
|
cursor: (!userRef.current?.getUser() || isLoggingIn) ? 'not-allowed' : 'pointer',
|
||||||
|
"&:disabled": {
|
||||||
|
opacity: 0.3,
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
},
|
||||||
|
fontSize: "4rem",
|
||||||
|
fontWeight: "600",
|
||||||
|
lineHeight: "0",
|
||||||
|
paddingBottom: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Profile Button */}
|
||||||
|
<Button
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
onClick={() => userRef.current?.getUser() ? setIsProfileHidden(false) : setIsLoginUserHidden(false)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
"&:hover": { backgroundColor: theme.palette.general.secondary },
|
||||||
|
flexShrink: 0,
|
||||||
|
height: "52px",
|
||||||
|
width: "52px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 0,
|
||||||
|
opacity: isLoggingIn ? 0.3 : 1,
|
||||||
|
cursor: isLoggingIn ? 'not-allowed' : 'pointer',
|
||||||
|
"&:disabled": {
|
||||||
|
opacity: 0.3,
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={ProfileIcon}
|
||||||
|
alt="Profile"
|
||||||
|
style={{ width: "70%", height: "70%", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal Views */}
|
{/* Terminal Views */}
|
||||||
<div className={`relative p-4 terminal-container ${getLayoutStyle()}`}>
|
<div className={`relative p-4 terminal-container ${getLayoutStyle()}`}>
|
||||||
{terminals.map((terminal) => (
|
{userRef.current?.getUser() ? (
|
||||||
|
terminals.map((terminal) => (
|
||||||
<div
|
<div
|
||||||
key={terminal.id}
|
key={terminal.id}
|
||||||
className={`bg-neutral-800 rounded-lg overflow-hidden shadow-xl border-5 border-neutral-700 ${
|
className={`bg-neutral-800 rounded-lg overflow-hidden shadow-xl border-5 border-neutral-700 ${
|
||||||
@@ -209,40 +624,141 @@ function App() {
|
|||||||
} flex-1`}
|
} flex-1`}
|
||||||
style={{
|
style={{
|
||||||
order: splitTabIds.includes(terminal.id)
|
order: splitTabIds.includes(terminal.id)
|
||||||
? splitTabIds.indexOf(terminal.id) + 1
|
? splitTabIds.indexOf(terminal.id)
|
||||||
: activeTab === terminal.id
|
: 0,
|
||||||
? 0
|
|
||||||
: undefined
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<NewTerminal
|
<NewTerminal
|
||||||
key={terminal.id}
|
key={terminal.id}
|
||||||
hostConfig={terminal.hostConfig}
|
hostConfig={terminal.hostConfig}
|
||||||
isVisible={activeTab === terminal.id || splitTabIds.includes(terminal.id)}
|
isVisible={activeTab === terminal.id || splitTabIds.includes(terminal.id)}
|
||||||
|
setIsNoAuthHidden={setIsNoAuthHidden}
|
||||||
ref={(ref) => {
|
ref={(ref) => {
|
||||||
if (ref && !terminal.terminalRef) {
|
terminal.terminalRef = ref;
|
||||||
setTerminals((prev) =>
|
|
||||||
prev.map((t) =>
|
|
||||||
t.id === terminal.id ? { ...t, terminalRef: ref } : t
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center text-neutral-400">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Welcome to Termix</h2>
|
||||||
|
<p>{isLoggingIn ? "Checking login status..." : "Please login to start managing your SSH connections"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<NoAuthenticationModal
|
||||||
|
isHidden={isNoAuthHidden}
|
||||||
|
form={authForm}
|
||||||
|
setForm={setAuthForm}
|
||||||
|
setIsNoAuthHidden={setIsNoAuthHidden}
|
||||||
|
handleAuthSubmit={handleAuthSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
|
{userRef.current?.getUser() && (
|
||||||
|
<>
|
||||||
<AddHostModal
|
<AddHostModal
|
||||||
isHidden={isAddHostHidden}
|
isHidden={isAddHostHidden}
|
||||||
form={form}
|
form={addHostForm}
|
||||||
setForm={setForm}
|
setForm={setAddHostForm}
|
||||||
handleAddHost={handleAddHost}
|
handleAddHost={handleAddHost}
|
||||||
setIsAddHostHidden={setIsAddHostHidden}
|
setIsAddHostHidden={setIsAddHostHidden}
|
||||||
/>
|
/>
|
||||||
{isLaunchpadOpen && <Launchpad onClose={() => setIsLaunchpadOpen(false)} />}
|
<EditHostModal
|
||||||
|
isHidden={isEditHostHidden}
|
||||||
|
form={editHostForm}
|
||||||
|
setForm={setEditHostForm}
|
||||||
|
handleEditHost={handleEditHost}
|
||||||
|
setIsEditHostHidden={setIsEditHostHidden}
|
||||||
|
hostConfig={currentHostConfig}
|
||||||
|
/>
|
||||||
|
<ProfileModal
|
||||||
|
isHidden={isProfileHidden}
|
||||||
|
getUser={getUser}
|
||||||
|
handleDeleteUser={handleDeleteUser}
|
||||||
|
handleLogoutUser={handleLogoutUser}
|
||||||
|
setIsProfileHidden={setIsProfileHidden}
|
||||||
|
/>
|
||||||
|
{isLaunchpadOpen && (
|
||||||
|
<Launchpad
|
||||||
|
onClose={() => setIsLaunchpadOpen(false)}
|
||||||
|
getHosts={getHosts}
|
||||||
|
connectToHost={connectToHostWithConfig}
|
||||||
|
isAddHostHidden={isAddHostHidden}
|
||||||
|
setIsAddHostHidden={setIsAddHostHidden}
|
||||||
|
isEditHostHidden={isEditHostHidden}
|
||||||
|
isErrorHidden={isErrorHidden}
|
||||||
|
deleteHost={deleteHost}
|
||||||
|
editHost={handleEditHost}
|
||||||
|
shareHost={(hostId, username) => userRef.current?.shareHost(hostId, username)}
|
||||||
|
userRef={userRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ErrorModal
|
||||||
|
isHidden={isErrorHidden}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
setIsErrorHidden={setIsErrorHidden}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoginUserModal
|
||||||
|
isHidden={isLoginUserHidden}
|
||||||
|
form={loginUserForm}
|
||||||
|
setForm={setLoginUserForm}
|
||||||
|
handleLoginUser={handleLoginUser}
|
||||||
|
handleGuestLogin={handleGuestLogin}
|
||||||
|
setIsLoginUserHidden={setIsLoginUserHidden}
|
||||||
|
setIsCreateUserHidden={setIsCreateUserHidden}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateUserModal
|
||||||
|
isHidden={isCreateUserHidden}
|
||||||
|
form={createUserForm}
|
||||||
|
setForm={setCreateUserForm}
|
||||||
|
handleCreateUser={handleCreateUser}
|
||||||
|
setIsCreateUserHidden={setIsCreateUserHidden}
|
||||||
|
setIsLoginUserHidden={setIsLoginUserHidden}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User component */}
|
||||||
|
<User
|
||||||
|
ref={userRef}
|
||||||
|
onLoginSuccess={() => {
|
||||||
|
setIsLoginUserHidden(true);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setIsErrorHidden(true);
|
||||||
|
}}
|
||||||
|
onCreateSuccess={() => {
|
||||||
|
setIsCreateUserHidden(true);
|
||||||
|
handleLoginUser({
|
||||||
|
username: createUserForm.username,
|
||||||
|
password: createUserForm.password,
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsLoginUserHidden(true);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setIsErrorHidden(true);
|
||||||
|
},
|
||||||
|
onFailure: (error) => {
|
||||||
|
setErrorMessage(`Login failed: ${error}`);
|
||||||
|
setIsErrorHidden(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDeleteSuccess={() => {
|
||||||
|
setIsProfileHidden(true);
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
onFailure={(error) => {
|
||||||
|
setErrorMessage(`Action failed: ${error}`);
|
||||||
|
setIsErrorHidden(false);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CssVarsProvider>
|
</CssVarsProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { CssVarsProvider } from '@mui/joy/styles';
|
|
||||||
import { Button } from '@mui/joy';
|
|
||||||
import theme from './theme';
|
|
||||||
|
|
||||||
function Launchpad({ onClose }) {
|
|
||||||
const launchpadRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (launchpadRef.current && !launchpadRef.current.contains(event.target)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CssVarsProvider theme={theme}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: "0",
|
|
||||||
left: "0",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.2)",
|
|
||||||
zIndex: 1000,
|
|
||||||
backdropFilter: "blur(5px)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={launchpadRef}
|
|
||||||
style={{
|
|
||||||
width: "75%",
|
|
||||||
height: "75%",
|
|
||||||
backgroundColor: theme.palette.general.tertiary,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: "8px",
|
|
||||||
boxShadow: "0 4px 10px rgba(0, 0, 0, 0.3)",
|
|
||||||
border: `1px solid ${theme.palette.general.secondary}`,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
padding: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">Launchpad</h2>
|
|
||||||
<p className="mb-4">A one-stop shop for adding hosts, apps (AI, notes, etc.), and all new features to come! Coming to you in a future update. Stay tuned!</p>
|
|
||||||
<p className="mb-4">
|
|
||||||
Can also be opened using <code className="bg-gray-500 px-1 rounded">Ctrl + L</code>
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={onClose}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.general.primary,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: theme.palette.general.disabled,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CssVarsProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Launchpad.propTypes = {
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Launchpad;
|
|
||||||
216
src/apps/Launchpad.jsx
Normal file
216
src/apps/Launchpad.jsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { CssVarsProvider } from '@mui/joy/styles';
|
||||||
|
import { Button } from '@mui/joy';
|
||||||
|
import HostViewerIcon from '../images/host_viewer_icon.png';
|
||||||
|
import theme from '../theme.js';
|
||||||
|
import HostViewer from './ssh/HostViewer.jsx';
|
||||||
|
|
||||||
|
function Launchpad({
|
||||||
|
onClose,
|
||||||
|
getHosts,
|
||||||
|
connectToHost,
|
||||||
|
isAddHostHidden,
|
||||||
|
setIsAddHostHidden,
|
||||||
|
isEditHostHidden,
|
||||||
|
isErrorHidden,
|
||||||
|
deleteHost,
|
||||||
|
editHost,
|
||||||
|
shareHost,
|
||||||
|
userRef,
|
||||||
|
}) {
|
||||||
|
const launchpadRef = useRef(null);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [activeApp, setActiveApp] = useState('hostViewer');
|
||||||
|
const [isAnyModalOpen, setIsAnyModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (
|
||||||
|
launchpadRef.current &&
|
||||||
|
!launchpadRef.current.contains(event.target) &&
|
||||||
|
isAddHostHidden &&
|
||||||
|
isEditHostHidden &&
|
||||||
|
isErrorHidden &&
|
||||||
|
!isAnyModalOpen
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden, isAnyModalOpen]);
|
||||||
|
|
||||||
|
const handleModalOpen = () => {
|
||||||
|
setIsAnyModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setIsAnyModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CssVarsProvider theme={theme}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: "0",
|
||||||
|
left: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.2)",
|
||||||
|
zIndex: 1000,
|
||||||
|
backdropFilter: "blur(5px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={launchpadRef}
|
||||||
|
style={{
|
||||||
|
width: "75%",
|
||||||
|
height: "75%",
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
display: "flex",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxShadow: "0 4px 10px rgba(0, 0, 0, 0.3)",
|
||||||
|
border: `1px solid ${theme.palette.general.secondary}`,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: sidebarOpen ? "200px" : "60px",
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
padding: "10px 5px",
|
||||||
|
transition: "width 0.3s ease",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRight: `1px solid ${theme.palette.general.secondary}`,
|
||||||
|
borderTopLeftRadius: "8px",
|
||||||
|
borderBottomLeftRadius: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sidebar Toggle Button */}
|
||||||
|
<Button
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.dark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: sidebarOpen ? "175px" : "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: "10px",
|
||||||
|
transition: "width 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarOpen ? "←" : "→"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* HostViewer Button */}
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveApp('hostViewer')}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: activeApp === 'hostViewer'
|
||||||
|
? theme.palette.general.tertiary
|
||||||
|
: theme.palette.general.primary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: activeApp === 'hostViewer'
|
||||||
|
? theme.palette.general.tertiary
|
||||||
|
: theme.palette.general.dark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: sidebarOpen ? "175px" : "40px",
|
||||||
|
height: "40px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "8px",
|
||||||
|
paddingLeft: sidebarOpen ? "15px" : "0",
|
||||||
|
transition: "width 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarOpen ? (
|
||||||
|
"Hosts"
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={HostViewerIcon}
|
||||||
|
alt="Host Viewer"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
style={{
|
||||||
|
objectFit: "contain",
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
{activeApp === 'hostViewer' && (
|
||||||
|
<HostViewer
|
||||||
|
getHosts={getHosts}
|
||||||
|
connectToHost={(hostConfig) => {
|
||||||
|
if (!hostConfig || typeof hostConfig !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hostConfig.ip || !hostConfig.user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectToHost(hostConfig);
|
||||||
|
}}
|
||||||
|
setIsAddHostHidden={setIsAddHostHidden}
|
||||||
|
deleteHost={deleteHost}
|
||||||
|
editHost={editHost}
|
||||||
|
openEditPanel={editHost}
|
||||||
|
shareHost={shareHost}
|
||||||
|
onModalOpen={handleModalOpen}
|
||||||
|
onModalClose={handleModalClose}
|
||||||
|
userRef={userRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Launchpad.propTypes = {
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
getHosts: PropTypes.func.isRequired,
|
||||||
|
connectToHost: PropTypes.func.isRequired,
|
||||||
|
isAddHostHidden: PropTypes.bool.isRequired,
|
||||||
|
setIsAddHostHidden: PropTypes.func.isRequired,
|
||||||
|
isEditHostHidden: PropTypes.bool.isRequired,
|
||||||
|
isErrorHidden: PropTypes.bool.isRequired,
|
||||||
|
deleteHost: PropTypes.func.isRequired,
|
||||||
|
editHost: PropTypes.func.isRequired,
|
||||||
|
shareHost: PropTypes.func.isRequired,
|
||||||
|
userRef: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Launchpad;
|
||||||
423
src/apps/ssh/HostViewer.jsx
Normal file
423
src/apps/ssh/HostViewer.jsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Button, Input } from "@mui/joy";
|
||||||
|
import ShareHostModal from "../../modals/ShareHostModal";
|
||||||
|
|
||||||
|
function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, editHost, openEditPanel, shareHost, onModalOpen, onModalClose, userRef }) {
|
||||||
|
const [hosts, setHosts] = useState([]);
|
||||||
|
const [filteredHosts, setFilteredHosts] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [collapsedFolders, setCollapsedFolders] = useState(new Set());
|
||||||
|
const [draggedHost, setDraggedHost] = useState(null);
|
||||||
|
const [isDraggingOver, setIsDraggingOver] = useState(null);
|
||||||
|
const isMounted = useRef(true);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isShareModalHidden, setIsShareModalHidden] = useState(true);
|
||||||
|
const [selectedHostForShare, setSelectedHostForShare] = useState(null);
|
||||||
|
|
||||||
|
const fetchHosts = async () => {
|
||||||
|
try {
|
||||||
|
const savedHosts = await getHosts();
|
||||||
|
if (isMounted.current) {
|
||||||
|
setHosts(savedHosts || []);
|
||||||
|
setFilteredHosts(savedHosts || []);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Host fetch failed:", error);
|
||||||
|
if (isMounted.current) {
|
||||||
|
setHosts([]);
|
||||||
|
setFilteredHosts([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMounted.current = true;
|
||||||
|
fetchHosts();
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
fetchHosts();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filtered = hosts.filter((hostWrapper) => {
|
||||||
|
const hostConfig = hostWrapper.config || {};
|
||||||
|
return hostConfig.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
hostConfig.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
hostConfig.folder?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
});
|
||||||
|
setFilteredHosts(filtered);
|
||||||
|
}, [searchTerm, hosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isShareModalHidden) {
|
||||||
|
onModalOpen();
|
||||||
|
} else {
|
||||||
|
onModalClose();
|
||||||
|
}
|
||||||
|
}, [isShareModalHidden, onModalOpen, onModalClose]);
|
||||||
|
|
||||||
|
const toggleFolder = (folderName) => {
|
||||||
|
setCollapsedFolders(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(folderName)) {
|
||||||
|
newSet.delete(folderName);
|
||||||
|
} else {
|
||||||
|
newSet.add(folderName);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupHostsByFolder = (hosts) => {
|
||||||
|
const grouped = {};
|
||||||
|
const noFolder = [];
|
||||||
|
|
||||||
|
const sortedHosts = [...hosts].sort((a, b) => {
|
||||||
|
const nameA = (a.config?.name || a.config?.ip || '').toLowerCase();
|
||||||
|
const nameB = (b.config?.name || b.config?.ip || '').toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedHosts.forEach(host => {
|
||||||
|
const folder = host.config?.folder;
|
||||||
|
if (folder) {
|
||||||
|
if (!grouped[folder]) {
|
||||||
|
grouped[folder] = [];
|
||||||
|
}
|
||||||
|
grouped[folder].push(host);
|
||||||
|
} else {
|
||||||
|
noFolder.push(host);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedFolders = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return { grouped, sortedFolders, noFolder };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (e, host) => {
|
||||||
|
setDraggedHost(host);
|
||||||
|
e.dataTransfer.setData('text/plain', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e, folderName) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingOver(folderName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setIsDraggingOver(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e, targetFolder) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDraggingOver(null);
|
||||||
|
|
||||||
|
if (!draggedHost) return;
|
||||||
|
|
||||||
|
if (draggedHost.config.folder === targetFolder) return;
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
...draggedHost.config,
|
||||||
|
folder: targetFolder
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await editHost(draggedHost.config, newConfig);
|
||||||
|
await fetchHosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update folder:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggedHost(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDropOnNoFolder = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDraggingOver(null);
|
||||||
|
|
||||||
|
if (!draggedHost || !draggedHost.config.folder) return;
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
...draggedHost.config,
|
||||||
|
folder: null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await editHost(draggedHost.config, newConfig);
|
||||||
|
await fetchHosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove from folder:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggedHost(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (e, hostWrapper) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isDeleting) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const isOwner = hostWrapper.createdBy?._id === userRef.current?.getUser()?.id;
|
||||||
|
if (isOwner) {
|
||||||
|
await deleteHost({ _id: hostWrapper._id });
|
||||||
|
} else {
|
||||||
|
await userRef.current.removeShare(hostWrapper._id);
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
await fetchHosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete/remove host:', error);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = async (hostId, username) => {
|
||||||
|
try {
|
||||||
|
await shareHost(hostId, username);
|
||||||
|
await fetchHosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to share host:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHostItem = (hostWrapper) => {
|
||||||
|
const hostConfig = hostWrapper.config || {};
|
||||||
|
const isOwner = hostWrapper.createdBy?._id === userRef.current?.getUser()?.id;
|
||||||
|
|
||||||
|
if (!hostConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={hostWrapper._id}
|
||||||
|
className={`flex justify-between items-center bg-neutral-800 p-3 rounded-lg shadow-md border border-neutral-700 w-full cursor-grab active:cursor-grabbing hover:border-neutral-500 transition-colors ${draggedHost === hostWrapper ? 'opacity-50' : ''}`}
|
||||||
|
draggable={isOwner}
|
||||||
|
onDragStart={(e) => isOwner && handleDragStart(e, hostWrapper)}
|
||||||
|
onDragEnd={() => setDraggedHost(null)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<div className="text-neutral-500 cursor-grab active:cursor-grabbing">⋮⋮</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-semibold">{hostConfig.name || hostConfig.ip}</p>
|
||||||
|
{!isOwner && (
|
||||||
|
<span className="text-xs bg-neutral-700 text-neutral-300 px-2 py-1 rounded">
|
||||||
|
Shared by {hostWrapper.createdBy?.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{hostConfig.user ? `${hostConfig.user}@${hostConfig.ip}` : `${hostConfig.ip}:${hostConfig.port}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="text-black"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!hostWrapper.config || !hostWrapper.config.ip || !hostWrapper.config.user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectToHost(hostWrapper.config);
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#6e6e6e",
|
||||||
|
"&:hover": { backgroundColor: "#0f0f0f" },
|
||||||
|
opacity: isDeleting ? 0.5 : 1,
|
||||||
|
cursor: isDeleting ? "not-allowed" : "pointer"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="text-black"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedHostForShare(hostWrapper);
|
||||||
|
setIsShareModalHidden(false);
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#6e6e6e",
|
||||||
|
"&:hover": { backgroundColor: "#0f0f0f" },
|
||||||
|
opacity: isDeleting ? 0.5 : 1,
|
||||||
|
cursor: isDeleting ? "not-allowed" : "pointer"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full p-4 text-white flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-2 w-full gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search hosts..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#6e6e6e",
|
||||||
|
color: "#fff",
|
||||||
|
"&::placeholder": { color: "#ccc" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="text-black"
|
||||||
|
onClick={() => setIsAddHostHidden(false)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#6e6e6e",
|
||||||
|
"&:hover": { backgroundColor: "#0f0f0f" }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Host
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow overflow-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-gray-300">Loading hosts...</p>
|
||||||
|
) : filteredHosts.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{(() => {
|
||||||
|
const { grouped, sortedFolders, noFolder } = groupHostsByFolder(filteredHosts);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Render hosts without folders first */}
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-2 p-2 rounded-lg transition-colors ${isDraggingOver === 'no-folder' ? 'bg-neutral-700' : ''}`}
|
||||||
|
onDragOver={(e) => handleDragOver(e, 'no-folder')}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDropOnNoFolder}
|
||||||
|
>
|
||||||
|
{noFolder.map((host) => renderHostItem(host))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render folders and their hosts */}
|
||||||
|
{sortedFolders.map((folderName) => (
|
||||||
|
<div key={folderName} className="mb-2">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 p-2 bg-neutral-600 rounded-lg cursor-pointer hover:bg-neutral-500 transition-colors ${
|
||||||
|
isDraggingOver === folderName ? 'bg-neutral-500 border-2 border-dashed border-neutral-400' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleFolder(folderName)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, folderName)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, folderName)}
|
||||||
|
>
|
||||||
|
<span className={`font-bold w-4 text-center transition-transform ${collapsedFolders.has(folderName) ? 'rotate-[-90deg]' : ''}`}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
<span className="font-bold">{folderName}</span>
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
({grouped[folderName].length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!collapsedFolders.has(folderName) && (
|
||||||
|
<div className="ml-6 mt-2 flex flex-col gap-2">
|
||||||
|
{grouped[folderName].map((host) => renderHostItem(host))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-300">No hosts available...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ShareHostModal
|
||||||
|
isHidden={isShareModalHidden}
|
||||||
|
setIsHidden={setIsShareModalHidden}
|
||||||
|
handleShare={handleShare}
|
||||||
|
hostConfig={selectedHostForShare}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HostViewer.propTypes = {
|
||||||
|
getHosts: PropTypes.func.isRequired,
|
||||||
|
connectToHost: PropTypes.func.isRequired,
|
||||||
|
setIsAddHostHidden: PropTypes.func.isRequired,
|
||||||
|
deleteHost: PropTypes.func.isRequired,
|
||||||
|
editHost: PropTypes.func.isRequired,
|
||||||
|
openEditPanel: PropTypes.func.isRequired,
|
||||||
|
shareHost: PropTypes.func.isRequired,
|
||||||
|
onModalOpen: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
userRef: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HostViewer;
|
||||||
@@ -4,9 +4,9 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import io from "socket.io-client";
|
import io from "socket.io-client";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import theme from "./theme";
|
import theme from "../../theme.js";
|
||||||
|
|
||||||
export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => {
|
export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidden }, ref) => {
|
||||||
const terminalRef = useRef(null);
|
const terminalRef = useRef(null);
|
||||||
const socketRef = useRef(null);
|
const socketRef = useRef(null);
|
||||||
const fitAddon = useRef(new FitAddon());
|
const fitAddon = useRef(new FitAddon());
|
||||||
@@ -55,30 +55,61 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => {
|
|||||||
terminalInstance.current.loadAddon(fitAddon.current);
|
terminalInstance.current.loadAddon(fitAddon.current);
|
||||||
terminalInstance.current.open(terminalRef.current);
|
terminalInstance.current.open(terminalRef.current);
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
fitAddon.current.fit();
|
|
||||||
resizeTerminal();
|
|
||||||
terminalInstance.current.focus();
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
const socket = io(
|
const socket = io(
|
||||||
window.location.hostname === "localhost"
|
window.location.hostname === "localhost"
|
||||||
? "http://localhost:8081"
|
? "http://localhost:8081"
|
||||||
: "/",
|
: "/",
|
||||||
{
|
{
|
||||||
path: "/socket.io",
|
path: "/ssh.io/socket.io",
|
||||||
transports: ["websocket", "polling"],
|
transports: ["websocket", "polling"],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on("connect_error", (error) => {
|
||||||
|
terminalInstance.current.write(`\r\n*** Socket connection error: ${error.message} ***\r\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect_timeout", () => {
|
||||||
|
terminalInstance.current.write(`\r\n*** Socket connection timeout ***\r\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (err) => {
|
||||||
|
const isAuthError = err.toLowerCase().includes("authentication") || err.toLowerCase().includes("auth");
|
||||||
|
if (isAuthError && !hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) {
|
||||||
|
authModalShown = true;
|
||||||
|
setIsNoAuthHidden(false);
|
||||||
|
}
|
||||||
|
terminalInstance.current.write(`\r\n*** Error: ${err} ***\r\n`);
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => {
|
||||||
fitAddon.current.fit();
|
fitAddon.current.fit();
|
||||||
resizeTerminal();
|
resizeTerminal();
|
||||||
const { cols, rows } = terminalInstance.current;
|
const { cols, rows } = terminalInstance.current;
|
||||||
socket.emit("connectToHost", cols, rows, hostConfig);
|
|
||||||
|
if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim()) {
|
||||||
|
setIsNoAuthHidden(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConfig = {
|
||||||
|
ip: hostConfig.ip,
|
||||||
|
user: hostConfig.user,
|
||||||
|
port: Number(hostConfig.port) || 22,
|
||||||
|
password: hostConfig.password?.trim(),
|
||||||
|
rsaKey: hostConfig.rsaKey?.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.emit("connectToHost", cols, rows, sshConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.current.fit();
|
||||||
|
resizeTerminal();
|
||||||
|
terminalInstance.current.focus();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
socket.on("data", (data) => {
|
socket.on("data", (data) => {
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
terminalInstance.current.write(decoder.decode(new Uint8Array(data)));
|
terminalInstance.current.write(decoder.decode(new Uint8Array(data)));
|
||||||
@@ -91,24 +122,41 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
terminalInstance.current.attachCustomKeyEventHandler((event) => {
|
terminalInstance.current.attachCustomKeyEventHandler((event) => {
|
||||||
console.log("Event caled");
|
|
||||||
if (isPasting) return;
|
|
||||||
|
|
||||||
isPasting = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
isPasting = false;
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "v") {
|
if ((event.ctrlKey || event.metaKey) && event.key === "v") {
|
||||||
|
if (isPasting) return false;
|
||||||
|
isPasting = true;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
navigator.clipboard.readText().then((text) => {
|
navigator.clipboard.readText().then((text) => {
|
||||||
socketRef.current.emit("data", text);
|
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
const lines = text.split("\n");
|
||||||
|
|
||||||
|
if (socketRef.current) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const sendLine = () => {
|
||||||
|
if (index < lines.length) {
|
||||||
|
socketRef.current.emit("data", lines[index] + "\r");
|
||||||
|
index++;
|
||||||
|
setTimeout(sendLine, 10);
|
||||||
|
} else {
|
||||||
|
isPasting = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sendLine();
|
||||||
|
} else {
|
||||||
|
isPasting = false;
|
||||||
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error("Failed to read clipboard contents:", err);
|
console.error("Failed to read clipboard contents:", err);
|
||||||
|
isPasting = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,13 +169,25 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("error", (err) => {
|
let authModalShown = false;
|
||||||
terminalInstance.current.write(`\r\n*** Error: ${err} ***\r\n`);
|
|
||||||
|
socket.on("noAuthRequired", () => {
|
||||||
|
if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) {
|
||||||
|
authModalShown = true;
|
||||||
|
setIsNoAuthHidden(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (terminalInstance.current) {
|
||||||
terminalInstance.current.dispose();
|
terminalInstance.current.dispose();
|
||||||
socket.disconnect();
|
terminalInstance.current = null;
|
||||||
|
}
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
authModalShown = false;
|
||||||
};
|
};
|
||||||
}, [hostConfig]);
|
}, [hostConfig]);
|
||||||
|
|
||||||
@@ -174,8 +234,10 @@ NewTerminal.propTypes = {
|
|||||||
hostConfig: PropTypes.shape({
|
hostConfig: PropTypes.shape({
|
||||||
ip: PropTypes.string.isRequired,
|
ip: PropTypes.string.isRequired,
|
||||||
user: PropTypes.string.isRequired,
|
user: PropTypes.string.isRequired,
|
||||||
password: PropTypes.string.isRequired,
|
password: PropTypes.string,
|
||||||
port: PropTypes.string.isRequired,
|
rsaKey: PropTypes.string,
|
||||||
|
port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
isVisible: PropTypes.bool.isRequired,
|
isVisible: PropTypes.bool.isRequired,
|
||||||
|
setIsNoAuthHidden: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
309
src/apps/user/User.jsx
Normal file
309
src/apps/user/User.jsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { useRef, forwardRef, useImperativeHandle, useEffect } from "react";
|
||||||
|
import io from "socket.io-client";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
const SOCKET_URL = window.location.hostname === "localhost"
|
||||||
|
? "http://localhost:8082/database.io"
|
||||||
|
: "/database.io";
|
||||||
|
|
||||||
|
const socket = io(SOCKET_URL, {
|
||||||
|
path: "/database.io/socket.io",
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
autoConnect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSuccess, onFailure }, ref) => {
|
||||||
|
const socketRef = useRef(socket);
|
||||||
|
const currentUser = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
socketRef.current.connect();
|
||||||
|
return () => socketRef.current.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const verifySession = async () => {
|
||||||
|
const storedSession = localStorage.getItem("sessionToken");
|
||||||
|
if (!storedSession || storedSession === "undefined") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("verifySession", { sessionToken: storedSession }, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
currentUser.current = {
|
||||||
|
id: response.user.id,
|
||||||
|
username: response.user.username,
|
||||||
|
sessionToken: storedSession,
|
||||||
|
};
|
||||||
|
onLoginSuccess(response.user);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("sessionToken");
|
||||||
|
onFailure("Session expired");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
verifySession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createUser = async (userConfig) => {
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("createUser", userConfig, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.user?.sessionToken) {
|
||||||
|
currentUser.current = {
|
||||||
|
id: response.user.id,
|
||||||
|
username: response.user.username,
|
||||||
|
sessionToken: response.user.sessionToken,
|
||||||
|
};
|
||||||
|
localStorage.setItem("sessionToken", response.user.sessionToken);
|
||||||
|
onCreateSuccess(response.user);
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.error || "User creation failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginUser = async ({ username, password, sessionToken }) => {
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
const credentials = sessionToken ? { sessionToken } : { username, password };
|
||||||
|
socketRef.current.emit("loginUser", credentials, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
currentUser.current = {
|
||||||
|
id: response.user.id,
|
||||||
|
username: response.user.username,
|
||||||
|
sessionToken: response.user.sessionToken,
|
||||||
|
};
|
||||||
|
localStorage.setItem("sessionToken", response.user.sessionToken);
|
||||||
|
onLoginSuccess(response.user);
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.error || "Login failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginAsGuest = async () => {
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("loginAsGuest", resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
currentUser.current = {
|
||||||
|
id: response.user.id,
|
||||||
|
username: response.user.username,
|
||||||
|
sessionToken: response.user.sessionToken,
|
||||||
|
};
|
||||||
|
localStorage.setItem("sessionToken", response.user.sessionToken);
|
||||||
|
onLoginSuccess(response.user);
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.error || "Guest login failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoutUser = () => {
|
||||||
|
localStorage.removeItem("sessionToken");
|
||||||
|
currentUser.current = null;
|
||||||
|
onLoginSuccess(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = async () => {
|
||||||
|
if (!currentUser.current) return onFailure("No user logged in");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("deleteUser", {
|
||||||
|
userId: currentUser.current.id,
|
||||||
|
sessionToken: currentUser.current.sessionToken,
|
||||||
|
}, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
logoutUser();
|
||||||
|
onDeleteSuccess(response);
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.error || "User deletion failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveHost = async (hostConfig) => {
|
||||||
|
if (!currentUser.current) return onFailure("Not authenticated");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("saveHostConfig", {
|
||||||
|
userId: currentUser.current.id,
|
||||||
|
sessionToken: currentUser.current.sessionToken,
|
||||||
|
...hostConfig
|
||||||
|
}, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Failed to save host");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllHosts = async () => {
|
||||||
|
if (!currentUser.current) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("getHosts", {
|
||||||
|
userId: currentUser.current.id,
|
||||||
|
sessionToken: currentUser.current.sessionToken,
|
||||||
|
}, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
return response.hosts.map(host => ({
|
||||||
|
...host,
|
||||||
|
config: host.config ? {
|
||||||
|
name: host.config.name || '',
|
||||||
|
folder: host.config.folder || '',
|
||||||
|
ip: host.config.ip || '',
|
||||||
|
user: host.config.user || '',
|
||||||
|
port: host.config.port || '22',
|
||||||
|
password: host.config.password || '',
|
||||||
|
rsaKey: host.config.rsaKey || '',
|
||||||
|
} : {}
|
||||||
|
})).filter(host => host.config && host.config.ip && host.config.user);
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.error || "Failed to fetch hosts");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHost = async ({ hostId }) => {
|
||||||
|
if (!currentUser.current) return onFailure("Not authenticated");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("deleteHost", {
|
||||||
|
userId: currentUser.current.id,
|
||||||
|
sessionToken: currentUser.current.sessionToken,
|
||||||
|
hostId: hostId,
|
||||||
|
}, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Failed to delete host");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editHost = async ({ oldHostConfig, newHostConfig }) => {
|
||||||
|
if (!currentUser.current) return onFailure("Not authenticated");
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Editing host with configs:', { oldHostConfig, newHostConfig });
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("editHost", {
|
||||||
|
userId: currentUser.current.id,
|
||||||
|
sessionToken: currentUser.current.sessionToken,
|
||||||
|
oldHostConfig,
|
||||||
|
newHostConfig,
|
||||||
|
}, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Failed to edit host");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareHost = async (hostId, targetUsername) => {
|
||||||
|
if (!currentUser.current) return onFailure("Not authenticated");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("shareHost", {
|
||||||
|
userId: currentUser.current.id,
|
||||||
|
sessionToken: currentUser.current.sessionToken,
|
||||||
|
hostId,
|
||||||
|
targetUsername,
|
||||||
|
}, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Failed to share host");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeShare = async (hostId) => {
|
||||||
|
if (!currentUser.current) return onFailure("Not authenticated");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve) => {
|
||||||
|
socketRef.current.emit("removeShare", {
|
||||||
|
userId: currentUser.current.id,
|
||||||
|
sessionToken: currentUser.current.sessionToken,
|
||||||
|
hostId,
|
||||||
|
}, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Failed to remove share");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
createUser,
|
||||||
|
loginUser,
|
||||||
|
loginAsGuest,
|
||||||
|
logoutUser,
|
||||||
|
deleteUser,
|
||||||
|
saveHost,
|
||||||
|
getAllHosts,
|
||||||
|
deleteHost,
|
||||||
|
shareHost,
|
||||||
|
editHost,
|
||||||
|
removeShare,
|
||||||
|
getUser: () => currentUser.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
User.displayName = "User";
|
||||||
|
|
||||||
|
User.propTypes = {
|
||||||
|
onLoginSuccess: PropTypes.func.isRequired,
|
||||||
|
onCreateSuccess: PropTypes.func.isRequired,
|
||||||
|
onDeleteSuccess: PropTypes.func.isRequired,
|
||||||
|
onFailure: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
460
src/backend/database.cjs
Normal file
460
src/backend/database.cjs
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const socketIo = require('socket.io');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (...args) => console.log(`🔧 [${new Date().toISOString()}] INFO:`, ...args),
|
||||||
|
error: (...args) => console.error(`❌ [${new Date().toISOString()}] ERROR:`, ...args),
|
||||||
|
warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args),
|
||||||
|
debug: (...args) => console.debug(`🔍 [${new Date().toISOString()}] DEBUG:`, ...args)
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer();
|
||||||
|
const io = socketIo(server, {
|
||||||
|
path: '/database.io/socket.io',
|
||||||
|
cors: { origin: '*', methods: ['GET', 'POST'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema({
|
||||||
|
username: { type: String, required: true, unique: true },
|
||||||
|
password: { type: String, required: true },
|
||||||
|
sessionToken: { type: String, required: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostSchema = new mongoose.Schema({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
config: { type: String, required: true },
|
||||||
|
users: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
|
||||||
|
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
||||||
|
folder: { type: String, default: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
const User = mongoose.model('User', userSchema);
|
||||||
|
const Host = mongoose.model('Host', hostSchema);
|
||||||
|
|
||||||
|
const getEncryptionKey = (userId, sessionToken) => {
|
||||||
|
const salt = process.env.SALT || 'default_salt';
|
||||||
|
return crypto.scryptSync(`${userId}-${sessionToken}`, salt, 32);
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptData = (data, userId, sessionToken) => {
|
||||||
|
try {
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', getEncryptionKey(userId, sessionToken), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(JSON.stringify(data)), cipher.final()]);
|
||||||
|
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${cipher.getAuthTag().toString('hex')}`;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Encryption failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptData = (encryptedData, userId, sessionToken) => {
|
||||||
|
try {
|
||||||
|
const [ivHex, contentHex, authTagHex] = encryptedData.split(':');
|
||||||
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
|
const content = Buffer.from(contentHex, 'hex');
|
||||||
|
const authTag = Buffer.from(authTagHex, 'hex');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', getEncryptionKey(userId, sessionToken), iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
return JSON.parse(Buffer.concat([decipher.update(content), decipher.final()]).toString());
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Decryption failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mongoose.connect(process.env.MONGO_URL || 'mongodb://localhost:27017/termix')
|
||||||
|
.then(() => logger.info('Connected to MongoDB'))
|
||||||
|
.catch(err => logger.error('MongoDB connection error:', err));
|
||||||
|
|
||||||
|
io.of('/database.io').on('connection', (socket) => {
|
||||||
|
socket.on('createUser', async ({ username, password }, callback) => {
|
||||||
|
try {
|
||||||
|
logger.debug(`Creating user: ${username}`);
|
||||||
|
|
||||||
|
if (await User.exists({ username })) {
|
||||||
|
logger.warn(`Username already exists: ${username}`);
|
||||||
|
return callback({ error: 'Username already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = crypto.randomBytes(64).toString('hex');
|
||||||
|
const user = await User.create({
|
||||||
|
username,
|
||||||
|
password: await bcrypt.hash(password, 10),
|
||||||
|
sessionToken
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User created: ${username}`);
|
||||||
|
callback({ success: true, user: {
|
||||||
|
id: user._id,
|
||||||
|
username: user.username,
|
||||||
|
sessionToken
|
||||||
|
}});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('User creation error:', error);
|
||||||
|
callback({ error: 'User creation failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('loginUser', async ({ username, password, sessionToken }, callback) => {
|
||||||
|
try {
|
||||||
|
let user;
|
||||||
|
if (sessionToken) {
|
||||||
|
user = await User.findOne({ sessionToken });
|
||||||
|
} else {
|
||||||
|
user = await User.findOne({ username });
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||||
|
logger.warn(`Invalid credentials for: ${username}`);
|
||||||
|
return callback({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn('Login failed - user not found');
|
||||||
|
return callback({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`User logged in: ${user.username}`);
|
||||||
|
callback({ success: true, user: {
|
||||||
|
id: user._id,
|
||||||
|
username: user.username,
|
||||||
|
sessionToken: user.sessionToken
|
||||||
|
}});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Login error:', error);
|
||||||
|
callback({ error: 'Login failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('loginAsGuest', async (callback) => {
|
||||||
|
try {
|
||||||
|
const username = `guest-${crypto.randomBytes(4).toString('hex')}`;
|
||||||
|
const sessionToken = crypto.randomBytes(64).toString('hex');
|
||||||
|
|
||||||
|
const user = await User.create({
|
||||||
|
username,
|
||||||
|
password: await bcrypt.hash(username, 10),
|
||||||
|
sessionToken
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Guest user created: ${username}`);
|
||||||
|
callback({ success: true, user: {
|
||||||
|
id: user._id,
|
||||||
|
username: user.username,
|
||||||
|
sessionToken
|
||||||
|
}});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Guest login error:', error);
|
||||||
|
callback({error: 'Guest login failed'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('saveHostConfig', async ({ userId, sessionToken, hostConfig }, callback) => {
|
||||||
|
try {
|
||||||
|
if (!userId || !sessionToken) {
|
||||||
|
logger.warn('Missing authentication parameters');
|
||||||
|
return callback({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostConfig || typeof hostConfig !== 'object') {
|
||||||
|
logger.warn('Invalid host config format');
|
||||||
|
return callback({ error: 'Invalid host configuration' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostConfig.ip || !hostConfig.user) {
|
||||||
|
logger.warn('Missing required fields:', hostConfig);
|
||||||
|
return callback({ error: 'IP and User are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ _id: userId, sessionToken });
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Invalid session for user: ${userId}`);
|
||||||
|
return callback({ error: 'Invalid session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanConfig = {
|
||||||
|
name: hostConfig.name?.trim(),
|
||||||
|
folder: hostConfig.folder?.trim() || null,
|
||||||
|
ip: hostConfig.ip.trim(),
|
||||||
|
user: hostConfig.user.trim(),
|
||||||
|
port: hostConfig.port || 22,
|
||||||
|
password: hostConfig.password?.trim() || undefined,
|
||||||
|
rsaKey: hostConfig.rsaKey?.trim() || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalName = cleanConfig.name || cleanConfig.ip;
|
||||||
|
|
||||||
|
const existingHost = await Host.findOne({
|
||||||
|
name: finalName,
|
||||||
|
createdBy: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingHost) {
|
||||||
|
logger.warn(`Host with name ${finalName} already exists for user: ${userId}`);
|
||||||
|
return callback({ error: 'Host with this name already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedConfig = encryptData(cleanConfig, userId, sessionToken);
|
||||||
|
if (!encryptedConfig) {
|
||||||
|
logger.error('Encryption failed for host config');
|
||||||
|
return callback({ error: 'Configuration encryption failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Host.create({
|
||||||
|
name: finalName,
|
||||||
|
config: encryptedConfig,
|
||||||
|
users: [userId],
|
||||||
|
createdBy: userId,
|
||||||
|
folder: cleanConfig.folder
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Host created successfully: ${finalName}`);
|
||||||
|
callback({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Host save error:', error);
|
||||||
|
callback({ error: `Host save failed: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('getHosts', async ({ userId, sessionToken }, callback) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findOne({ _id: userId, sessionToken });
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Invalid session for user: ${userId}`);
|
||||||
|
return callback({ error: 'Invalid session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = await Host.find({ users: userId }).populate('createdBy');
|
||||||
|
const decryptedHosts = await Promise.all(hosts.map(async host => {
|
||||||
|
try {
|
||||||
|
const ownerUser = host.createdBy;
|
||||||
|
if (!ownerUser) {
|
||||||
|
logger.warn(`Owner not found for host: ${host._id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedConfig = decryptData(host.config, ownerUser._id.toString(), ownerUser.sessionToken);
|
||||||
|
if (!decryptedConfig) {
|
||||||
|
logger.warn(`Failed to decrypt host config for host: ${host._id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...host.toObject(),
|
||||||
|
config: decryptedConfig
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to process host ${host._id}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
callback({ success: true, hosts: decryptedHosts.filter(host => host && host.config) });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Get hosts error:', error);
|
||||||
|
callback({ error: 'Failed to fetch hosts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('deleteHost', async ({ userId, sessionToken, hostId }, callback) => {
|
||||||
|
try {
|
||||||
|
logger.debug(`Deleting host: ${hostId} for user: ${userId}`);
|
||||||
|
|
||||||
|
if (!userId || !sessionToken) {
|
||||||
|
logger.warn('Missing authentication parameters');
|
||||||
|
return callback({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostId || typeof hostId !== 'string') {
|
||||||
|
logger.warn('Invalid host ID format');
|
||||||
|
return callback({ error: 'Invalid host ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ _id: userId, sessionToken });
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Invalid session for user: ${userId}`);
|
||||||
|
return callback({ error: 'Invalid session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Host.deleteOne({ _id: hostId, createdBy: userId });
|
||||||
|
if (result.deletedCount === 0) {
|
||||||
|
logger.warn(`Host not found or not authorized: ${hostId}`);
|
||||||
|
return callback({ error: 'Host not found or not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Host deleted: ${hostId}`);
|
||||||
|
callback({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Host deletion error:', error);
|
||||||
|
callback({ error: `Host deletion failed: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('shareHost', async ({ userId, sessionToken, hostId, targetUsername }, callback) => {
|
||||||
|
try {
|
||||||
|
logger.debug(`Sharing host ${hostId} with ${targetUsername}`);
|
||||||
|
|
||||||
|
const user = await User.findOne({ _id: userId, sessionToken });
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Invalid session for user: ${userId}`);
|
||||||
|
return callback({ error: 'Invalid session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await User.findOne({ username: targetUsername });
|
||||||
|
if (!targetUser) {
|
||||||
|
logger.warn(`Target user not found: ${targetUsername}`);
|
||||||
|
return callback({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await Host.findOne({ _id: hostId, createdBy: userId });
|
||||||
|
if (!host) {
|
||||||
|
logger.warn(`Host not found or unauthorized: ${hostId}`);
|
||||||
|
return callback({ error: 'Host not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.users.includes(targetUser._id)) {
|
||||||
|
logger.warn(`Host already shared with user: ${targetUsername}`);
|
||||||
|
return callback({ error: 'Already shared' });
|
||||||
|
}
|
||||||
|
|
||||||
|
host.users.push(targetUser._id);
|
||||||
|
await host.save();
|
||||||
|
|
||||||
|
logger.info(`Host shared successfully: ${hostId} -> ${targetUsername}`);
|
||||||
|
callback({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Host sharing error:', error);
|
||||||
|
callback({ error: 'Failed to share host' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('removeShare', async ({ userId, sessionToken, hostId }, callback) => {
|
||||||
|
try {
|
||||||
|
logger.debug(`Removing share for host ${hostId} from user ${userId}`);
|
||||||
|
|
||||||
|
const user = await User.findOne({ _id: userId, sessionToken });
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Invalid session for user: ${userId}`);
|
||||||
|
return callback({ error: 'Invalid session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await Host.findById(hostId);
|
||||||
|
if (!host) {
|
||||||
|
logger.warn(`Host not found: ${hostId}`);
|
||||||
|
return callback({ error: 'Host not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
host.users = host.users.filter(id => id.toString() !== userId);
|
||||||
|
await host.save();
|
||||||
|
|
||||||
|
logger.info(`Share removed successfully: ${hostId} -> ${userId}`);
|
||||||
|
callback({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Share removal error:', error);
|
||||||
|
callback({ error: 'Failed to remove share' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('deleteUser', async ({ userId, sessionToken }, callback) => {
|
||||||
|
try {
|
||||||
|
logger.debug(`Deleting user: ${userId}`);
|
||||||
|
|
||||||
|
const user = await User.findOne({ _id: userId, sessionToken });
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Invalid session for user: ${userId}`);
|
||||||
|
return callback({ error: 'Invalid session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Host.deleteMany({ createdBy: userId });
|
||||||
|
await User.deleteOne({ _id: userId });
|
||||||
|
|
||||||
|
logger.info(`User deleted: ${userId}`);
|
||||||
|
callback({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('User deletion error:', error);
|
||||||
|
callback({ error: 'Failed to delete user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("editHost", async ({ userId, sessionToken, oldHostConfig, newHostConfig }, callback) => {
|
||||||
|
try {
|
||||||
|
logger.debug(`Editing host for user: ${userId}`);
|
||||||
|
|
||||||
|
if (!oldHostConfig || !newHostConfig) {
|
||||||
|
logger.warn('Missing host configurations');
|
||||||
|
return callback({ error: 'Missing host configurations' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ _id: userId, sessionToken });
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Invalid session for user: ${userId}`);
|
||||||
|
return callback({ error: 'Invalid session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = await Host.find({ createdBy: userId });
|
||||||
|
const host = hosts.find(h => {
|
||||||
|
const decryptedConfig = decryptData(h.config, userId, sessionToken);
|
||||||
|
return decryptedConfig && decryptedConfig.ip === oldHostConfig.ip;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
logger.warn(`Host not found or unauthorized`);
|
||||||
|
return callback({ error: 'Host not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanConfig = {
|
||||||
|
name: newHostConfig.name?.trim(),
|
||||||
|
folder: newHostConfig.folder?.trim() || null,
|
||||||
|
ip: newHostConfig.ip.trim(),
|
||||||
|
user: newHostConfig.user.trim(),
|
||||||
|
port: newHostConfig.port || 22,
|
||||||
|
password: newHostConfig.password?.trim() || undefined,
|
||||||
|
rsaKey: newHostConfig.rsaKey?.trim() || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptedConfig = encryptData(cleanConfig, userId, sessionToken);
|
||||||
|
if (!encryptedConfig) {
|
||||||
|
logger.error('Encryption failed for host config');
|
||||||
|
return callback({ error: 'Configuration encryption failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
host.config = encryptedConfig;
|
||||||
|
host.folder = cleanConfig.folder;
|
||||||
|
await host.save();
|
||||||
|
|
||||||
|
logger.info(`Host edited successfully`);
|
||||||
|
callback({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Host edit error:', error);
|
||||||
|
callback({ error: 'Failed to edit host' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('verifySession', async ({ sessionToken }, callback) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findOne({ sessionToken });
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Invalid session token: ${sessionToken}`);
|
||||||
|
return callback({ error: 'Invalid session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
callback({ success: true, user: {
|
||||||
|
id: user._id,
|
||||||
|
username: user.username
|
||||||
|
}});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Session verification error:', error);
|
||||||
|
callback({ error: 'Session verification failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8082, () => {
|
||||||
|
logger.info('Server running on port 8082');
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ const SSHClient = require("ssh2").Client;
|
|||||||
|
|
||||||
const server = http.createServer();
|
const server = http.createServer();
|
||||||
const io = socketIo(server, {
|
const io = socketIo(server, {
|
||||||
|
path: "/ssh.io/socket.io",
|
||||||
cors: {
|
cors: {
|
||||||
origin: "*",
|
origin: "*",
|
||||||
methods: ["GET", "POST"],
|
methods: ["GET", "POST"],
|
||||||
@@ -12,77 +13,84 @@ const io = socketIo(server, {
|
|||||||
allowEIO3: true
|
allowEIO3: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (...args) => console.log(`🔧 [${new Date().toISOString()}] INFO:`, ...args),
|
||||||
|
error: (...args) => console.error(`❌ [${new Date().toISOString()}] ERROR:`, ...args),
|
||||||
|
warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args),
|
||||||
|
debug: (...args) => console.debug(`🔍 [${new Date().toISOString()}] DEBUG:`, ...args)
|
||||||
|
};
|
||||||
|
|
||||||
io.on("connection", (socket) => {
|
io.on("connection", (socket) => {
|
||||||
console.log("New socket connection established");
|
logger.info("New socket connection established");
|
||||||
|
|
||||||
let stream = null;
|
let stream = null;
|
||||||
|
|
||||||
socket.on("connectToHost", (cols, rows, hostConfig) => {
|
socket.on("connectToHost", (cols, rows, hostConfig) => {
|
||||||
if (!hostConfig || !hostConfig.ip || !hostConfig.user || (!hostConfig.password && !hostConfig.rsaKey) || !hostConfig.port) {
|
if (!hostConfig || !hostConfig.ip || !hostConfig.user || !hostConfig.port) {
|
||||||
console.error("Invalid hostConfig received:", hostConfig);
|
logger.error("Invalid hostConfig received - missing required fields:", hostConfig);
|
||||||
|
socket.emit("error", "Missing required connection details (IP, user, or port)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostConfig.password && !hostConfig.rsaKey) {
|
||||||
|
logger.error("No authentication provided");
|
||||||
|
socket.emit("error", "Authentication required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redact only sensitive info for logging
|
|
||||||
const safeHostConfig = {
|
const safeHostConfig = {
|
||||||
ip: hostConfig.ip,
|
ip: hostConfig.ip,
|
||||||
port: hostConfig.port,
|
port: hostConfig.port,
|
||||||
user: hostConfig.user,
|
user: hostConfig.user,
|
||||||
password: hostConfig.password ? '***REDACTED***' : undefined,
|
authType: hostConfig.password ? 'password' : 'public key',
|
||||||
rsaKey: hostConfig.rsaKey ? '***REDACTED***' : undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Received hostConfig:", safeHostConfig);
|
logger.info("Connecting with config:", safeHostConfig);
|
||||||
const { ip, port, user, password, rsaKey } = hostConfig;
|
const { ip, port, user, password, rsaKey } = hostConfig;
|
||||||
|
|
||||||
const conn = new SSHClient();
|
const conn = new SSHClient();
|
||||||
conn
|
conn
|
||||||
.on("ready", function () {
|
.on("ready", function () {
|
||||||
console.log("SSH connection established");
|
logger.info("SSH connection established");
|
||||||
|
|
||||||
conn.shell({ term: "xterm-256color" }, function (err, newStream) {
|
conn.shell({ term: "xterm-256color" }, function (err, newStream) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Error:", err.message);
|
logger.error("Shell error:", err.message);
|
||||||
socket.emit("error", err.message);
|
socket.emit("error", err.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stream = newStream;
|
stream = newStream;
|
||||||
|
|
||||||
// Set initial terminal size
|
|
||||||
stream.setWindow(rows, cols, rows * 100, cols * 100);
|
stream.setWindow(rows, cols, rows * 100, cols * 100);
|
||||||
|
|
||||||
// Pipe SSH output to client
|
|
||||||
stream.on("data", function (data) {
|
stream.on("data", function (data) {
|
||||||
socket.emit("data", data);
|
socket.emit("data", data);
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", function () {
|
stream.on("close", function () {
|
||||||
console.log("SSH stream closed");
|
logger.info("SSH stream closed");
|
||||||
conn.end();
|
conn.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send keystrokes from terminal to SSH
|
|
||||||
socket.on("data", function (data) {
|
socket.on("data", function (data) {
|
||||||
stream.write(data);
|
stream.write(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resize SSH terminal when client resizes
|
|
||||||
socket.on("resize", ({ cols, rows }) => {
|
socket.on("resize", ({ cols, rows }) => {
|
||||||
if (stream && stream.setWindow) {
|
if (stream && stream.setWindow) {
|
||||||
stream.setWindow(rows, cols, rows * 100, cols * 100);
|
stream.setWindow(rows, cols, rows * 100, cols * 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-send initial terminal size to backend
|
|
||||||
socket.emit("resize", { cols, rows });
|
socket.emit("resize", { cols, rows });
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.on("close", function () {
|
.on("close", function () {
|
||||||
console.log("SSH connection closed");
|
logger.info("SSH connection closed");
|
||||||
socket.emit("error", "SSH connection closed");
|
socket.emit("error", "SSH connection closed");
|
||||||
})
|
})
|
||||||
.on("error", function (err) {
|
.on("error", function (err) {
|
||||||
console.error("Error:", err.message);
|
logger.error("Error:", err.message);
|
||||||
socket.emit("error", err.message);
|
socket.emit("error", err.message);
|
||||||
})
|
})
|
||||||
.connect({
|
.connect({
|
||||||
@@ -95,10 +103,10 @@ io.on("connection", (socket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
console.log("Client disconnected");
|
logger.info("Client disconnected");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(8081, '0.0.0.0', () => {
|
server.listen(8081, '0.0.0.0', () => {
|
||||||
console.log("Server is running on port 8081");
|
logger.info("Server is running on port 8081");
|
||||||
});
|
});
|
||||||
BIN
src/images/host_viewer_icon.png
Normal file
BIN
src/images/host_viewer_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
src/images/profile_icon.png
Normal file
BIN
src/images/profile_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -1,10 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
|
||||||
)
|
)
|
||||||
394
src/modals/AddHostModal.jsx
Normal file
394
src/modals/AddHostModal.jsx
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { CssVarsProvider } from '@mui/joy/styles';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Stack,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
ModalDialog,
|
||||||
|
Select,
|
||||||
|
Option,
|
||||||
|
Checkbox,
|
||||||
|
IconButton,
|
||||||
|
Tabs,
|
||||||
|
TabList,
|
||||||
|
Tab,
|
||||||
|
TabPanel
|
||||||
|
} from '@mui/joy';
|
||||||
|
import theme from '/src/theme';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
|
|
||||||
|
const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidden }) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.name.endsWith('.key') || file.name.endsWith('.pem') || file.name.endsWith('.pub')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
setForm({ ...form, rsaKey: event.target.result });
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
} else {
|
||||||
|
alert("Please upload a valid public key file.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthChange = (newMethod) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authMethod: newMethod,
|
||||||
|
password: "",
|
||||||
|
rsaKey: ""
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
if (!form.ip || !form.user || !form.port) return false;
|
||||||
|
const portNum = Number(form.port);
|
||||||
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false;
|
||||||
|
|
||||||
|
if (form.rememberHost) {
|
||||||
|
if (form.authMethod === 'Select Auth') return false;
|
||||||
|
if (form.authMethod === 'rsaKey' && !form.rsaKey) return false;
|
||||||
|
if (form.authMethod === 'password' && !form.password) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isFormValid()) {
|
||||||
|
if (!form.rememberHost) {
|
||||||
|
handleAddHost();
|
||||||
|
} else {
|
||||||
|
handleAddHost();
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm({
|
||||||
|
name: '',
|
||||||
|
folder: '',
|
||||||
|
ip: '',
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
rsaKey: '',
|
||||||
|
port: 22,
|
||||||
|
authMethod: 'Select Auth',
|
||||||
|
rememberHost: false,
|
||||||
|
storePassword: true,
|
||||||
|
});
|
||||||
|
setIsAddHostHidden(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CssVarsProvider theme={theme}>
|
||||||
|
<Modal open={!isHidden} onClose={() => setIsAddHostHidden(true)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalDialog
|
||||||
|
layout="center"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
borderColor: theme.palette.general.secondary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
padding: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
mx: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ mb: 2 }}>Add Host</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(e, val) => setActiveTab(val)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabList
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
gap: 0,
|
||||||
|
mb: 2,
|
||||||
|
'& button': {
|
||||||
|
flex: 1,
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: theme.palette.general.primary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab>Basic Info</Tab>
|
||||||
|
<Tab>Connection</Tab>
|
||||||
|
<Tab>Authentication</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanel value={0}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Host Name</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Folder</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={form.folder || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, folder: e.target.value })}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={1}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<FormControl error={!form.ip}>
|
||||||
|
<FormLabel>Host IP</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={form.ip}
|
||||||
|
onChange={(e) => setForm({ ...form, ip: e.target.value })}
|
||||||
|
required
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.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 error={form.port < 1 || form.port > 65535}>
|
||||||
|
<FormLabel>Host Port</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm({ ...form, port: e.target.value })}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
required
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={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'}>
|
||||||
|
<FormLabel>Authentication Method</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={form.authMethod}
|
||||||
|
onChange={(e, val) => handleAuthChange(val)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Option value="Select Auth" disabled>Select Auth</Option>
|
||||||
|
<Option value="password">Password</Option>
|
||||||
|
<Option value="rsaKey">Public Key</Option>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{form.authMethod === 'password' && (
|
||||||
|
<FormControl error={!form.password}>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.authMethod === 'rsaKey' && (
|
||||||
|
<FormControl error={!form.rsaKey}>
|
||||||
|
<FormLabel>Public Key</FormLabel>
|
||||||
|
<Button
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '40px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.rsaKey ? 'Change Public Key File' : 'Upload Public Key File'}
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
sx={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid()}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
},
|
||||||
|
'&:disabled': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
marginTop: 3,
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Host
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AddHostModal.propTypes = {
|
||||||
|
isHidden: PropTypes.bool.isRequired,
|
||||||
|
form: PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
folder: PropTypes.string,
|
||||||
|
ip: PropTypes.string.isRequired,
|
||||||
|
user: PropTypes.string.isRequired,
|
||||||
|
password: PropTypes.string,
|
||||||
|
rsaKey: PropTypes.string,
|
||||||
|
port: PropTypes.number.isRequired,
|
||||||
|
authMethod: PropTypes.string.isRequired,
|
||||||
|
rememberHost: PropTypes.bool,
|
||||||
|
storePassword: PropTypes.bool,
|
||||||
|
}).isRequired,
|
||||||
|
setForm: PropTypes.func.isRequired,
|
||||||
|
handleAddHost: PropTypes.func.isRequired,
|
||||||
|
setIsAddHostHidden: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddHostModal;
|
||||||
166
src/modals/CreateUserModal.jsx
Normal file
166
src/modals/CreateUserModal.jsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { CssVarsProvider } from '@mui/joy/styles';
|
||||||
|
import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog, IconButton } from '@mui/joy';
|
||||||
|
import theme from '/src/theme';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
|
|
||||||
|
const CreateUserModal = ({ isHidden, form, setForm, handleCreateUser, setIsCreateUserHidden, setIsLoginUserHidden }) => {
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
if (!form.username || !form.password || form.password !== confirmPassword) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
handleCreateUser({
|
||||||
|
...form
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHidden) {
|
||||||
|
setForm({ username: '', password: '' });
|
||||||
|
setConfirmPassword('');
|
||||||
|
}
|
||||||
|
}, [isHidden]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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;
|
||||||
396
src/modals/EditHostModal.jsx
Normal file
396
src/modals/EditHostModal.jsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CssVarsProvider } from '@mui/joy/styles';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Stack,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
ModalDialog,
|
||||||
|
Select,
|
||||||
|
Option,
|
||||||
|
IconButton,
|
||||||
|
Checkbox,
|
||||||
|
Tabs,
|
||||||
|
TabList,
|
||||||
|
Tab,
|
||||||
|
TabPanel
|
||||||
|
} from '@mui/joy';
|
||||||
|
import theme from '/src/theme';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
|
|
||||||
|
const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostHidden, hostConfig }) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHidden && hostConfig) {
|
||||||
|
setForm({
|
||||||
|
name: hostConfig.name || '',
|
||||||
|
folder: hostConfig.folder || '',
|
||||||
|
ip: hostConfig.ip || '',
|
||||||
|
user: hostConfig.user || '',
|
||||||
|
password: hostConfig.password || '',
|
||||||
|
rsaKey: hostConfig.rsaKey || '',
|
||||||
|
port: hostConfig.port || 22,
|
||||||
|
authMethod: hostConfig.password ? 'password' : hostConfig.rsaKey ? 'rsaKey' : 'Select Auth',
|
||||||
|
rememberHost: true,
|
||||||
|
storePassword: !!(hostConfig.password || hostConfig.rsaKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isHidden, hostConfig]);
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file.name.endsWith('.rsa') || file.name.endsWith('.key') || file.name.endsWith('.pem') || file.name.endsWith('.der') || file.name.endsWith('.p8') || file.name.endsWith('.ssh') || file.name.endsWith('.pub')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (evt) => {
|
||||||
|
setForm((prev) => ({ ...prev, rsaKey: evt.target.result }));
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
} else {
|
||||||
|
alert('Please upload a valid RSA private key file.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthChange = (newMethod) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authMethod: newMethod
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStorePasswordChange = (checked) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
storePassword: Boolean(checked),
|
||||||
|
password: checked ? prev.password : "",
|
||||||
|
rsaKey: checked ? prev.rsaKey : "",
|
||||||
|
authMethod: checked ? prev.authMethod : "Select Auth"
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
const { ip, user, port, authMethod, password, rsaKey, storePassword } = form;
|
||||||
|
if (!ip?.trim() || !user?.trim() || !port) return false;
|
||||||
|
const portNum = Number(port);
|
||||||
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false;
|
||||||
|
|
||||||
|
if (Boolean(storePassword) && authMethod === 'password' && !password?.trim()) return false;
|
||||||
|
if (Boolean(storePassword) && authMethod === 'rsaKey' && !rsaKey && !hostConfig?.rsaKey) return false;
|
||||||
|
if (Boolean(storePassword) && authMethod === 'Select Auth') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await handleEditHost(hostConfig, {
|
||||||
|
name: form.name || form.ip,
|
||||||
|
folder: form.folder,
|
||||||
|
ip: form.ip,
|
||||||
|
user: form.user,
|
||||||
|
password: form.authMethod === 'password' ? form.password : undefined,
|
||||||
|
rsaKey: form.authMethod === 'rsaKey' ? form.rsaKey : undefined,
|
||||||
|
port: String(form.port),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CssVarsProvider theme={theme}>
|
||||||
|
<Modal
|
||||||
|
open={!isHidden}
|
||||||
|
onClose={() => !isLoading && setIsEditHostHidden(true)}
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backdropFilter: 'blur(5px)',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalDialog
|
||||||
|
layout="center"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
borderColor: theme.palette.general.secondary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
padding: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
mx: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ mb: 2 }}>Edit Host</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(e, val) => setActiveTab(val)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabList
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
gap: 0,
|
||||||
|
mb: 2,
|
||||||
|
'& button': {
|
||||||
|
flex: 1,
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: theme.palette.general.primary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab>Basic Info</Tab>
|
||||||
|
<Tab>Connection</Tab>
|
||||||
|
<Tab>Authentication</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanel value={0}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Host Name</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Folder</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={form.folder}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, folder: e.target.value }))}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={1}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<FormControl error={!form.ip}>
|
||||||
|
<FormLabel>Host IP</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={form.ip}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, ip: e.target.value }))}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl error={form.port < 1 || form.port > 65535}>
|
||||||
|
<FormLabel>Host Port</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.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>
|
||||||
|
</Stack>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={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 && (
|
||||||
|
<FormControl error={form.authMethod === 'Select Auth'}>
|
||||||
|
<FormLabel>Authentication Method</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={form.authMethod}
|
||||||
|
onChange={(e, val) => handleAuthChange(val)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Option value="Select Auth" disabled>Select Auth</Option>
|
||||||
|
<Option value="password">Password</Option>
|
||||||
|
<Option value="rsaKey">Public Key</Option>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.authMethod === 'password' && form.storePassword && (
|
||||||
|
<FormControl error={!form.password}>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, password: e.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.authMethod === 'rsaKey' && form.storePassword && (
|
||||||
|
<FormControl error={!form.rsaKey && !hostConfig?.rsaKey}>
|
||||||
|
<FormLabel>Public Key</FormLabel>
|
||||||
|
<Button
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '40px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.rsaKey ? 'Change Public Key File' : 'Upload Public Key File'}
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
sx={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
{hostConfig?.rsaKey && !form.rsaKey && (
|
||||||
|
<FormLabel
|
||||||
|
sx={{
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
mt: 1,
|
||||||
|
display: 'block',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Existing key detected. Upload to replace.
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid() || isLoading}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.disabled
|
||||||
|
},
|
||||||
|
'&:disabled': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
marginTop: 3,
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditHostModal.propTypes = {
|
||||||
|
isHidden: PropTypes.bool.isRequired,
|
||||||
|
form: PropTypes.object.isRequired,
|
||||||
|
setForm: PropTypes.func.isRequired,
|
||||||
|
handleEditHost: PropTypes.func.isRequired,
|
||||||
|
setIsEditHostHidden: PropTypes.func.isRequired,
|
||||||
|
hostConfig: PropTypes.object
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditHostModal;
|
||||||
56
src/modals/ErrorModal.jsx
Normal file
56
src/modals/ErrorModal.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { CssVarsProvider } from '@mui/joy/styles';
|
||||||
|
import { Modal, Button, DialogTitle, DialogContent, ModalDialog } from '@mui/joy';
|
||||||
|
import theme from '/src/theme';
|
||||||
|
|
||||||
|
const ErrorModal = ({ isHidden, errorMessage, setIsErrorHidden }) => {
|
||||||
|
return (
|
||||||
|
<CssVarsProvider theme={theme}>
|
||||||
|
<Modal open={!isHidden} onClose={() => setIsErrorHidden(true)}>
|
||||||
|
<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",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ marginBottom: 1.5 }}>Error</DialogTitle>
|
||||||
|
<DialogContent sx={{ color: theme.palette.text.primary }}>
|
||||||
|
{errorMessage}
|
||||||
|
</DialogContent>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsErrorHidden(true)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ErrorModal.propTypes = {
|
||||||
|
isHidden: PropTypes.bool.isRequired,
|
||||||
|
errorMessage: PropTypes.string.isRequired,
|
||||||
|
setIsErrorHidden: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorModal;
|
||||||
150
src/modals/LoginUserModal.jsx
Normal file
150
src/modals/LoginUserModal.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { CssVarsProvider } from '@mui/joy/styles';
|
||||||
|
import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog, IconButton } from '@mui/joy';
|
||||||
|
import theme from '/src/theme';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
|
|
||||||
|
const LoginUserModal = ({ isHidden, form, setForm, handleLoginUser, handleGuestLogin, setIsLoginUserHidden, setIsCreateUserHidden }) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
if (!form.username || !form.password) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
handleLoginUser({
|
||||||
|
...form,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHidden) {
|
||||||
|
setForm({ username: '', password: '' });
|
||||||
|
}
|
||||||
|
}, [isHidden]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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;
|
||||||
200
src/modals/NoAuthenticationModal.jsx
Normal file
200
src/modals/NoAuthenticationModal.jsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { CssVarsProvider } from '@mui/joy/styles';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Stack,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
ModalDialog,
|
||||||
|
IconButton,
|
||||||
|
Select,
|
||||||
|
Option,
|
||||||
|
} from '@mui/joy';
|
||||||
|
import theme from '/src/theme';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
|
|
||||||
|
const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, handleAuthSubmit }) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form.authMethod) {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
authMethod: 'Select Auth'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
if (!form.authMethod || form.authMethod === 'Select Auth') return false;
|
||||||
|
if (form.authMethod === 'rsaKey' && !form.rsaKey) return false;
|
||||||
|
if (form.authMethod === 'password' && !form.password) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isFormValid()) {
|
||||||
|
handleAuthSubmit(form);
|
||||||
|
setForm({ authMethod: 'Select Auth', password: '', rsaKey: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CssVarsProvider theme={theme}>
|
||||||
|
<Modal
|
||||||
|
open={!isHidden}
|
||||||
|
onClose={(e, reason) => {
|
||||||
|
if (reason !== 'backdropClick') {
|
||||||
|
setIsNoAuthHidden(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalDialog
|
||||||
|
layout="center"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
borderColor: theme.palette.general.secondary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
padding: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
mx: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ mb: 2 }}>Authentication Required</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<FormControl error={!form.authMethod || form.authMethod === 'Select Auth'}>
|
||||||
|
<FormLabel>Authentication Method</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={form.authMethod || 'Select Auth'}
|
||||||
|
onChange={(e, val) => setForm(prev => ({ ...prev, authMethod: val, password: '', rsaKey: '' }))}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Option value="Select Auth" disabled>Select Auth</Option>
|
||||||
|
<Option value="password">Password</Option>
|
||||||
|
<Option value="rsaKey">Public Key</Option>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{form.authMethod === 'password' && (
|
||||||
|
<FormControl error={!form.password}>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={form.password || ''}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, password: e.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.authMethod === 'rsaKey' && (
|
||||||
|
<FormControl error={!form.rsaKey}>
|
||||||
|
<FormLabel>Public Key</FormLabel>
|
||||||
|
<Button
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '40px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.rsaKey ? 'Change Public Key File' : 'Upload Public Key File'}
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => {
|
||||||
|
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' }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid()}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.disabled,
|
||||||
|
},
|
||||||
|
'&:disabled': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
marginTop: 2,
|
||||||
|
height: '40px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
NoAuthenticationModal.propTypes = {
|
||||||
|
isHidden: PropTypes.bool.isRequired,
|
||||||
|
form: PropTypes.object.isRequired,
|
||||||
|
setForm: PropTypes.func.isRequired,
|
||||||
|
setIsNoAuthHidden: PropTypes.func.isRequired,
|
||||||
|
handleAuthSubmit: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NoAuthenticationModal;
|
||||||
86
src/modals/ProfileModal.jsx
Normal file
86
src/modals/ProfileModal.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Modal, Button } from "@mui/joy";
|
||||||
|
import LogoutIcon from "@mui/icons-material/Logout";
|
||||||
|
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
|
||||||
|
import theme from "../theme";
|
||||||
|
|
||||||
|
export default function ProfileModal({
|
||||||
|
isHidden,
|
||||||
|
handleDeleteUser,
|
||||||
|
handleLogoutUser,
|
||||||
|
setIsProfileHidden,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={!isHidden}
|
||||||
|
onClose={() => setIsProfileHidden(true)}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
borderColor: theme.palette.general.secondary,
|
||||||
|
borderWidth: "1px",
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
width: "400px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
onClick={handleLogoutUser}
|
||||||
|
startDecorator={<LogoutIcon />}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
color: "white",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.general.secondary,
|
||||||
|
},
|
||||||
|
height: "40px",
|
||||||
|
border: `1px solid ${theme.palette.general.secondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
color="danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm("Are you sure you want to delete your account? This action cannot be undone.")) {
|
||||||
|
handleDeleteUser({
|
||||||
|
onSuccess: () => setIsProfileHidden(true),
|
||||||
|
onFailure: (error) => console.error(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
startDecorator={<DeleteForeverIcon />}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#c53030",
|
||||||
|
color: "white",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#9b2c2c",
|
||||||
|
},
|
||||||
|
height: "40px",
|
||||||
|
border: "1px solid #9b2c2c",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileModal.propTypes = {
|
||||||
|
isHidden: PropTypes.bool.isRequired,
|
||||||
|
getUser: PropTypes.func.isRequired,
|
||||||
|
handleDeleteUser: PropTypes.func.isRequired,
|
||||||
|
handleLogoutUser: PropTypes.func.isRequired,
|
||||||
|
setIsProfileHidden: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
123
src/modals/ShareHostModal.jsx
Normal file
123
src/modals/ShareHostModal.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CssVarsProvider } from '@mui/joy/styles';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
ModalDialog,
|
||||||
|
} from '@mui/joy';
|
||||||
|
import theme from '/src/theme';
|
||||||
|
|
||||||
|
const ShareHostModal = ({ isHidden, setIsHidden, handleShare, hostConfig }) => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isLoading || !username.trim()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await handleShare(hostConfig._id, username.trim());
|
||||||
|
setUsername('');
|
||||||
|
setIsHidden(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CssVarsProvider theme={theme}>
|
||||||
|
<Modal
|
||||||
|
open={!isHidden}
|
||||||
|
onClose={() => !isLoading && setIsHidden(true)}
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backdropFilter: 'blur(5px)',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalDialog
|
||||||
|
layout="center"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleModalClick}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.tertiary,
|
||||||
|
borderColor: theme.palette.general.secondary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
padding: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
maxWidth: '400px',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
mx: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ mb: 2 }}>Share Host</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleSubmit} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<FormControl error={!username.trim()}>
|
||||||
|
<FormLabel>Username to share with</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter username"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
mb: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!username.trim() || isLoading}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: theme.palette.general.primary,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.general.disabled
|
||||||
|
},
|
||||||
|
'&:disabled': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? "Sharing..." : "Share"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
</CssVarsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ShareHostModal.propTypes = {
|
||||||
|
isHidden: PropTypes.bool.isRequired,
|
||||||
|
setIsHidden: PropTypes.func.isRequired,
|
||||||
|
handleShare: PropTypes.func.isRequired,
|
||||||
|
hostConfig: PropTypes.object
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShareHostModal;
|
||||||
@@ -5,4 +5,10 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
ignored: ["**/docker/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user