Dev 2.0 #23
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Notify via ntfy
|
||||
run: |
|
||||
curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \
|
||||
https://ntfy.karmaashomepage.online/termix-build
|
||||
https://ntfy.karmaa.site/termix-build
|
||||
|
||||
- name: Delete all untagged image versions
|
||||
uses: quartx-analytics/ghcr-cleaner@v1
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -157,4 +157,8 @@ typings/
|
||||
.dotnet/
|
||||
|
||||
# .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 />
|
||||
<p align="center">
|
||||
@@ -29,26 +32,31 @@ Termix is an open-source forever free self-hosted SSH (other protocols planned,
|
||||
# Features
|
||||
- SSH
|
||||
- Split Screen (Up to 4) & Tab System
|
||||
- User Authentication
|
||||
- Save Hosts (and easily view, connect, and manage them)
|
||||
|
||||
# Planned Features
|
||||
- Database to Store Connection Details
|
||||
- VNC
|
||||
- RDP
|
||||
- SFTP (build in file transfer)
|
||||
- ChatGPT/Ollama Integration (for commands)
|
||||
- Login Screen
|
||||
- User Management
|
||||
- Apps (like notes, AI, etc)
|
||||
- Terminal Themes
|
||||
- User Management (roles, permissions, etc.)
|
||||
- SSH Tunneling
|
||||
- More Authentication Methods
|
||||
- More Security Features (like 2FA, etc.)
|
||||
|
||||
# Installation
|
||||
Visit the Termix [Wiki](https://github.com/LukeGus/Termix/wiki) for information on how to install Termix. You can also use these links to go directly to guide. [Docker](https://github.com/LukeGus/Termix/wiki/Docker) or [Manual](https://github.com/LukeGus/Termix/wiki/Manual).
|
||||
|
||||
# Support
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo. If you would like to support me financially, you can on [Paypal](https://paypal.me/LukeGustafson803)
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo. If you would like to support me financially, you can on [Paypal](https://paypal.me/LukeGustafson803).
|
||||
|
||||
# Show-off
|
||||
|
||||

|
||||

|
||||
|
||||
# License
|
||||
Distributed under the MIT license. See LICENSE for more information.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:18 AS frontend-builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
@@ -7,31 +7,53 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build backend
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS backend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:18 AS backend-builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY src/backend/ ./src/backend/
|
||||
|
||||
# Stage 3: Final production image
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache nginx
|
||||
FROM mongo:5
|
||||
# Install Node.js
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
nginx \
|
||||
python3 \
|
||||
build-essential \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Configure nginx
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy backend
|
||||
COPY --from=backend-builder /app/node_modules ./node_modules
|
||||
# Setup backend
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=backend-builder /app/src/backend ./src/backend
|
||||
|
||||
# Create separate directories for nginx and node
|
||||
RUN mkdir -p /var/log/nginx && \
|
||||
# Create directories for MongoDB and nginx
|
||||
RUN mkdir -p /data/db && \
|
||||
mkdir -p /var/log/nginx && \
|
||||
mkdir -p /var/lib/nginx && \
|
||||
chown -R nginx:nginx /var/log/nginx /var/lib/nginx
|
||||
mkdir -p /var/log/mongodb && \
|
||||
chown -R mongodb:mongodb /data/db /var/log/mongodb && \
|
||||
chown -R www-data:www-data /var/log/nginx /var/lib/nginx
|
||||
|
||||
# Set environment variables
|
||||
ENV MONGO_URL=mongodb://localhost:27017/termix \
|
||||
MONGODB_DATA_DIR=/data/db \
|
||||
MONGODB_LOG_DIR=/var/log/mongodb
|
||||
|
||||
# Create volume for MongoDB data
|
||||
VOLUME ["/data/db"]
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 8080 8081
|
||||
EXPOSE 8080 8081 8082 27017
|
||||
|
||||
# Use a entrypoint script to run all services
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Start NGINX in background
|
||||
nginx -g "daemon off;" &
|
||||
# Start MongoDB
|
||||
echo "Starting MongoDB..."
|
||||
mongod --fork --dbpath $MONGODB_DATA_DIR --logpath $MONGODB_LOG_DIR/mongodb.log
|
||||
|
||||
# Start Node.js backend
|
||||
node src/backend/server.cjs
|
||||
# Wait for MongoDB to be ready
|
||||
echo "Waiting for MongoDB to start..."
|
||||
until mongosh --eval "print(\"waited for connection\")" > /dev/null 2>&1; do
|
||||
sleep 0.5
|
||||
done
|
||||
echo "MongoDB has started"
|
||||
|
||||
# Keep container running
|
||||
wait
|
||||
# Start nginx
|
||||
echo "Starting nginx..."
|
||||
nginx
|
||||
|
||||
# Change to app directory
|
||||
cd /app
|
||||
|
||||
# Start the SSH service
|
||||
echo "Starting SSH service..."
|
||||
node src/backend/ssh.cjs &
|
||||
|
||||
# Start the database service
|
||||
echo "Starting database service..."
|
||||
node src/backend/database.cjs &
|
||||
|
||||
# Keep the container running and show MongoDB logs
|
||||
echo "All services started. Tailing MongoDB logs..."
|
||||
tail -f $MONGODB_LOG_DIR/mongodb.log
|
||||
@@ -19,18 +19,32 @@ http {
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
# Proxy IO requests
|
||||
location /socket.io/ {
|
||||
# Proxy SSH socket requests
|
||||
location /ssh.io/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeout settings
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy MongoDB socket requests
|
||||
location /database.io/ {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
|
||||
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/styled": "^11.14.0",
|
||||
"@fontsource/inter": "^5.1.1",
|
||||
"@mui/icons-material": "^6.4.7",
|
||||
"@mui/joy": "^5.0.0-beta.51",
|
||||
"@tailwindcss/vite": "^4.0.8",
|
||||
"@tiptap/extension-link": "^2.11.5",
|
||||
@@ -21,12 +22,16 @@
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"embla-carousel-react": "^7.1.0",
|
||||
"express": "^4.21.2",
|
||||
"is-stream": "^4.0.1",
|
||||
"make-dir": "^5.0.0",
|
||||
"mongoose": "^8.12.1",
|
||||
"node-ssh": "^13.2.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
|
||||
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;
|
||||
704
src/App.jsx
704
src/App.jsx
@@ -1,30 +1,75 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { NewTerminal } from "./Terminal.jsx";
|
||||
import AddHostModal from "./AddHostModal.jsx";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { NewTerminal } from "./apps/ssh/Terminal.jsx";
|
||||
import { User } from "./apps/user/User.jsx";
|
||||
import AddHostModal from "./modals/AddHostModal.jsx";
|
||||
import LoginUserModal from "./modals/LoginUserModal.jsx";
|
||||
import { Button } from "@mui/joy";
|
||||
import { CssVarsProvider } from "@mui/joy";
|
||||
import theme from "./theme";
|
||||
import TabList from "./TabList.jsx";
|
||||
import Launchpad from "./Launchpad.jsx";
|
||||
import { Debounce } from './Utils';
|
||||
import TabList from "./ui/TabList.jsx";
|
||||
import Launchpad from "./apps/Launchpad.jsx";
|
||||
import { Debounce } from './other/Utils.jsx';
|
||||
import TermixIcon from "./images/termix_icon.png";
|
||||
import RocketIcon from './images/launchpad_rocket.png';
|
||||
import ProfileIcon from './images/profile_icon.png';
|
||||
import CreateUserModal from "./modals/CreateUserModal.jsx";
|
||||
import ProfileModal from "./modals/ProfileModal.jsx";
|
||||
import ErrorModal from "./modals/ErrorModal.jsx";
|
||||
import EditHostModal from "./modals/EditHostModal.jsx";
|
||||
import NoAuthenticationModal from "./modals/NoAuthenticationModal.jsx";
|
||||
|
||||
function App() {
|
||||
const [isAddHostHidden, setIsAddHostHidden] = useState(true);
|
||||
const [isLoginUserHidden, setIsLoginUserHidden] = useState(true);
|
||||
const [isCreateUserHidden, setIsCreateUserHidden] = useState(true);
|
||||
const [isProfileHidden, setIsProfileHidden] = useState(true);
|
||||
const [isErrorHidden, setIsErrorHidden] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [terminals, setTerminals] = useState([]);
|
||||
const userRef = useRef(null);
|
||||
const [activeTab, setActiveTab] = useState(null);
|
||||
const [nextId, setNextId] = useState(1);
|
||||
const [form, setForm] = useState({
|
||||
const [addHostForm, setAddHostForm] = useState({
|
||||
name: "",
|
||||
folder: "",
|
||||
ip: "",
|
||||
user: "",
|
||||
password: "",
|
||||
port: 22,
|
||||
authMethod: "Select Auth",
|
||||
rememberHost: false,
|
||||
storePassword: true,
|
||||
});
|
||||
const [editHostForm, setEditHostForm] = useState({
|
||||
name: "",
|
||||
folder: "",
|
||||
ip: "",
|
||||
user: "",
|
||||
password: "",
|
||||
port: 22,
|
||||
authMethod: "Select Auth",
|
||||
rememberHost: true,
|
||||
storePassword: true,
|
||||
});
|
||||
const [isNoAuthHidden, setIsNoAuthHidden] = useState(true);
|
||||
const [authForm, setAuthForm] = useState({
|
||||
password: "",
|
||||
rsaKey: "",
|
||||
});
|
||||
const [loginUserForm, setLoginUserForm] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [createUserForm, setCreateUserForm] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [isLaunchpadOpen, setIsLaunchpadOpen] = useState(false);
|
||||
const [splitTabIds, setSplitTabIds] = useState([]);
|
||||
const [isEditHostHidden, setIsEditHostHidden] = useState(true);
|
||||
const [currentHostConfig, setCurrentHostConfig] = useState(null);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
@@ -81,27 +126,347 @@ function App() {
|
||||
});
|
||||
}, [splitTabIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const sessionToken = localStorage.getItem('sessionToken');
|
||||
let isComponentMounted = true;
|
||||
let isLoginInProgress = false;
|
||||
|
||||
if (userRef.current?.getUser()) {
|
||||
setIsLoggingIn(false);
|
||||
setIsLoginUserHidden(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionToken) {
|
||||
setIsLoggingIn(false);
|
||||
setIsLoginUserHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoggingIn(true);
|
||||
let loginAttempts = 0;
|
||||
const maxAttempts = 50;
|
||||
let attemptLoginInterval;
|
||||
|
||||
const loginTimeout = setTimeout(() => {
|
||||
if (isComponentMounted) {
|
||||
clearInterval(attemptLoginInterval);
|
||||
if (!userRef.current?.getUser()) {
|
||||
localStorage.removeItem('sessionToken');
|
||||
setIsLoginUserHidden(false);
|
||||
setIsLoggingIn(false);
|
||||
setErrorMessage('Login timed out. Please try again.');
|
||||
setIsErrorHidden(false);
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
const attemptLogin = () => {
|
||||
if (!isComponentMounted || isLoginInProgress) return;
|
||||
|
||||
if (loginAttempts >= maxAttempts || userRef.current?.getUser()) {
|
||||
clearTimeout(loginTimeout);
|
||||
clearInterval(attemptLoginInterval);
|
||||
|
||||
if (!userRef.current?.getUser()) {
|
||||
localStorage.removeItem('sessionToken');
|
||||
setIsLoginUserHidden(false);
|
||||
setIsLoggingIn(false);
|
||||
setErrorMessage('Login timed out. Please try again.');
|
||||
setIsErrorHidden(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (userRef.current) {
|
||||
isLoginInProgress = true;
|
||||
userRef.current.loginUser({
|
||||
sessionToken,
|
||||
onSuccess: () => {
|
||||
if (isComponentMounted) {
|
||||
clearTimeout(loginTimeout);
|
||||
clearInterval(attemptLoginInterval);
|
||||
setIsLoginUserHidden(true);
|
||||
setIsLoggingIn(false);
|
||||
setIsErrorHidden(true);
|
||||
}
|
||||
isLoginInProgress = false;
|
||||
},
|
||||
onFailure: (error) => {
|
||||
if (isComponentMounted) {
|
||||
if (!userRef.current?.getUser()) {
|
||||
clearTimeout(loginTimeout);
|
||||
clearInterval(attemptLoginInterval);
|
||||
localStorage.removeItem('sessionToken');
|
||||
setErrorMessage(`Auto-login failed: ${error}`);
|
||||
setIsErrorHidden(false);
|
||||
setIsLoginUserHidden(false);
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
}
|
||||
isLoginInProgress = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
loginAttempts++;
|
||||
};
|
||||
|
||||
attemptLoginInterval = setInterval(attemptLogin, 100);
|
||||
attemptLogin();
|
||||
|
||||
return () => {
|
||||
isComponentMounted = false;
|
||||
clearTimeout(loginTimeout);
|
||||
clearInterval(attemptLoginInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddHost = () => {
|
||||
if (form.ip && form.user && ((form.authMethod === 'password' && form.password) || (form.authMethod === 'rsaKey' && form.rsaKey)) && form.port) {
|
||||
const newTerminal = {
|
||||
id: nextId,
|
||||
title: form.name || form.ip,
|
||||
hostConfig: {
|
||||
ip: form.ip,
|
||||
user: form.user,
|
||||
password: form.authMethod === 'password' ? form.password : undefined,
|
||||
rsaKey: form.authMethod === 'rsaKey' ? form.rsaKey : undefined,
|
||||
port: String(form.port),
|
||||
},
|
||||
terminalRef: null,
|
||||
};
|
||||
setTerminals([...terminals, newTerminal]);
|
||||
setActiveTab(nextId);
|
||||
setNextId(nextId + 1);
|
||||
if (addHostForm.ip && addHostForm.user && addHostForm.port) {
|
||||
if (!addHostForm.rememberHost) {
|
||||
connectToHost();
|
||||
setIsAddHostHidden(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (addHostForm.authMethod === 'Select Auth') {
|
||||
alert("Please select an authentication method.");
|
||||
return;
|
||||
}
|
||||
if (addHostForm.authMethod === 'password' && !addHostForm.password) {
|
||||
setIsNoAuthHidden(false);
|
||||
return;
|
||||
}
|
||||
if (addHostForm.authMethod === 'rsaKey' && !addHostForm.rsaKey) {
|
||||
setIsNoAuthHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
connectToHost();
|
||||
if (!addHostForm.storePassword) {
|
||||
addHostForm.password = '';
|
||||
}
|
||||
handleSaveHost();
|
||||
setIsAddHostHidden(true);
|
||||
setForm({ name: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth" });
|
||||
} else {
|
||||
alert("Please fill out all fields.");
|
||||
alert("Please fill out all required fields (IP, User, Port).");
|
||||
}
|
||||
};
|
||||
|
||||
const connectToHost = () => {
|
||||
const hostConfig = {
|
||||
name: addHostForm.name || '',
|
||||
folder: addHostForm.folder || '',
|
||||
ip: addHostForm.ip,
|
||||
user: addHostForm.user,
|
||||
port: String(addHostForm.port),
|
||||
password: addHostForm.rememberHost && addHostForm.authMethod === 'password' ? addHostForm.password : undefined,
|
||||
rsaKey: addHostForm.rememberHost && addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined,
|
||||
};
|
||||
|
||||
const newTerminal = {
|
||||
id: nextId,
|
||||
title: hostConfig.name || hostConfig.ip,
|
||||
hostConfig,
|
||||
terminalRef: null,
|
||||
};
|
||||
setTerminals([...terminals, newTerminal]);
|
||||
setActiveTab(nextId);
|
||||
setNextId(nextId + 1);
|
||||
setIsAddHostHidden(true);
|
||||
setAddHostForm({ name: "", folder: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth", rememberHost: false, storePassword: true });
|
||||
}
|
||||
|
||||
const handleAuthSubmit = (form) => {
|
||||
const updatedTerminals = terminals.map((terminal) => {
|
||||
if (terminal.id === activeTab) {
|
||||
return {
|
||||
...terminal,
|
||||
hostConfig: {
|
||||
...terminal.hostConfig,
|
||||
password: form.password,
|
||||
rsaKey: form.rsaKey
|
||||
}
|
||||
};
|
||||
}
|
||||
return terminal;
|
||||
});
|
||||
setTerminals(updatedTerminals);
|
||||
setIsNoAuthHidden(true);
|
||||
};
|
||||
|
||||
const connectToHostWithConfig = (hostConfig) => {
|
||||
if (!hostConfig || typeof hostConfig !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.ip || !hostConfig.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanHostConfig = {
|
||||
name: hostConfig.name || '',
|
||||
folder: hostConfig.folder || '',
|
||||
ip: hostConfig.ip.trim(),
|
||||
user: hostConfig.user.trim(),
|
||||
port: hostConfig.port || '22',
|
||||
password: hostConfig.password?.trim(),
|
||||
rsaKey: hostConfig.rsaKey?.trim(),
|
||||
};
|
||||
|
||||
const newTerminal = {
|
||||
id: nextId,
|
||||
title: cleanHostConfig.name || cleanHostConfig.ip,
|
||||
hostConfig: cleanHostConfig,
|
||||
terminalRef: null,
|
||||
};
|
||||
setTerminals([...terminals, newTerminal]);
|
||||
setActiveTab(nextId);
|
||||
setNextId(nextId + 1);
|
||||
setIsLaunchpadOpen(false);
|
||||
}
|
||||
|
||||
const handleSaveHost = () => {
|
||||
let hostConfig = {
|
||||
name: addHostForm.name || addHostForm.ip,
|
||||
folder: addHostForm.folder,
|
||||
ip: addHostForm.ip,
|
||||
user: addHostForm.user,
|
||||
password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined,
|
||||
rsaKey: addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined,
|
||||
port: String(addHostForm.port),
|
||||
}
|
||||
if (userRef.current) {
|
||||
userRef.current.saveHost({
|
||||
hostConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginUser = ({ username, password, sessionToken, onSuccess, onFailure }) => {
|
||||
if (userRef.current) {
|
||||
if (sessionToken) {
|
||||
userRef.current.loginUser({
|
||||
sessionToken,
|
||||
onSuccess: () => {
|
||||
setIsLoginUserHidden(true);
|
||||
setIsLoggingIn(false);
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onFailure: (error) => {
|
||||
localStorage.removeItem('sessionToken');
|
||||
setIsLoginUserHidden(false);
|
||||
setIsLoggingIn(false);
|
||||
if (onFailure) onFailure(error);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
userRef.current.loginUser({
|
||||
username,
|
||||
password,
|
||||
onSuccess: () => {
|
||||
setIsLoginUserHidden(true);
|
||||
setIsLoggingIn(false);
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onFailure: (error) => {
|
||||
setIsLoginUserHidden(false);
|
||||
setIsLoggingIn(false);
|
||||
if (onFailure) onFailure(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGuestLogin = () => {
|
||||
if (userRef.current) {
|
||||
userRef.current.loginAsGuest();
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = ({ username, password, onSuccess, onFailure }) => {
|
||||
if (userRef.current) {
|
||||
userRef.current.createUser({
|
||||
username,
|
||||
password,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = ({ onSuccess, onFailure }) => {
|
||||
if (userRef.current) {
|
||||
userRef.current.deleteUser({
|
||||
onSuccess,
|
||||
onFailure,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoutUser = () => {
|
||||
if (userRef.current) {
|
||||
userRef.current.logoutUser();
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const getUser = () => {
|
||||
if (userRef.current) {
|
||||
return userRef.current.getUser();
|
||||
}
|
||||
}
|
||||
|
||||
const getHosts = () => {
|
||||
if (userRef.current) {
|
||||
return userRef.current.getAllHosts();
|
||||
}
|
||||
}
|
||||
|
||||
const deleteHost = (hostConfig) => {
|
||||
if (userRef.current) {
|
||||
userRef.current.deleteHost({
|
||||
hostId: hostConfig._id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateEditHostForm = (hostConfig) => {
|
||||
if (hostConfig) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
setIsEditHostHidden(false);
|
||||
} else {
|
||||
console.error("hostConfig is null");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditHost = async (oldConfig, newConfig = null) => {
|
||||
try {
|
||||
if (newConfig) {
|
||||
if (isEditing) return;
|
||||
setIsEditing(true);
|
||||
|
||||
try {
|
||||
await userRef.current.editHost({
|
||||
oldHostConfig: oldConfig,
|
||||
newHostConfig: newConfig,
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
setIsEditHostHidden(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateEditHostForm(oldConfig);
|
||||
} catch (error) {
|
||||
console.error('Edit failed:', error);
|
||||
setErrorMessage(`Edit failed: ${error}`);
|
||||
setIsErrorHidden(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,85 +529,236 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Launchpad Button */}
|
||||
<Button
|
||||
onClick={() => setIsLaunchpadOpen(true)}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.general.tertiary,
|
||||
"&:hover": { backgroundColor: theme.palette.general.secondary },
|
||||
flexShrink: 0,
|
||||
height: "52px",
|
||||
width: "52px",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<img src={RocketIcon} alt="Launchpad" style={{ width: "70%", height: "70", objectFit: "contain" }} />
|
||||
</Button>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-4">
|
||||
{/* Launchpad Button */}
|
||||
<Button
|
||||
disabled={isLoggingIn || !userRef.current?.getUser()}
|
||||
onClick={() => setIsLaunchpadOpen(true)}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.general.tertiary,
|
||||
"&:hover": { backgroundColor: theme.palette.general.secondary },
|
||||
flexShrink: 0,
|
||||
height: "52px",
|
||||
width: "52px",
|
||||
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" }} />
|
||||
</Button>
|
||||
|
||||
{/* Add Host Button */}
|
||||
<Button
|
||||
onClick={() => setIsAddHostHidden(false)}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.general.tertiary,
|
||||
"&:hover": { backgroundColor: theme.palette.general.secondary },
|
||||
flexShrink: 0,
|
||||
height: "52px",
|
||||
width: "52px",
|
||||
fontSize: "3.5rem",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingTop: "2px",
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
{/* Add Host Button */}
|
||||
<Button
|
||||
disabled={isLoggingIn || !userRef.current?.getUser()}
|
||||
onClick={() => setIsAddHostHidden(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: (!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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Terminal Views */}
|
||||
<div className={`relative p-4 terminal-container ${getLayoutStyle()}`}>
|
||||
{terminals.map((terminal) => (
|
||||
<div
|
||||
key={terminal.id}
|
||||
className={`bg-neutral-800 rounded-lg overflow-hidden shadow-xl border-5 border-neutral-700 ${
|
||||
splitTabIds.includes(terminal.id) || activeTab === terminal.id ? "block" : "hidden"
|
||||
} flex-1`}
|
||||
style={{
|
||||
order: splitTabIds.includes(terminal.id)
|
||||
? splitTabIds.indexOf(terminal.id) + 1
|
||||
: activeTab === terminal.id
|
||||
? 0
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<NewTerminal
|
||||
{userRef.current?.getUser() ? (
|
||||
terminals.map((terminal) => (
|
||||
<div
|
||||
key={terminal.id}
|
||||
hostConfig={terminal.hostConfig}
|
||||
isVisible={activeTab === terminal.id || splitTabIds.includes(terminal.id)}
|
||||
ref={(ref) => {
|
||||
if (ref && !terminal.terminalRef) {
|
||||
setTerminals((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === terminal.id ? { ...t, terminalRef: ref } : t
|
||||
)
|
||||
);
|
||||
}
|
||||
className={`bg-neutral-800 rounded-lg overflow-hidden shadow-xl border-5 border-neutral-700 ${
|
||||
splitTabIds.includes(terminal.id) || activeTab === terminal.id ? "block" : "hidden"
|
||||
} flex-1`}
|
||||
style={{
|
||||
order: splitTabIds.includes(terminal.id)
|
||||
? splitTabIds.indexOf(terminal.id)
|
||||
: 0,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<NewTerminal
|
||||
key={terminal.id}
|
||||
hostConfig={terminal.hostConfig}
|
||||
isVisible={activeTab === terminal.id || splitTabIds.includes(terminal.id)}
|
||||
setIsNoAuthHidden={setIsNoAuthHidden}
|
||||
ref={(ref) => {
|
||||
terminal.terminalRef = ref;
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
)}
|
||||
<NoAuthenticationModal
|
||||
isHidden={isNoAuthHidden}
|
||||
form={authForm}
|
||||
setForm={setAuthForm}
|
||||
setIsNoAuthHidden={setIsNoAuthHidden}
|
||||
handleAuthSubmit={handleAuthSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<AddHostModal
|
||||
isHidden={isAddHostHidden}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
handleAddHost={handleAddHost}
|
||||
setIsAddHostHidden={setIsAddHostHidden}
|
||||
/>
|
||||
{isLaunchpadOpen && <Launchpad onClose={() => setIsLaunchpadOpen(false)} />}
|
||||
{/* Modals */}
|
||||
{userRef.current?.getUser() && (
|
||||
<>
|
||||
<AddHostModal
|
||||
isHidden={isAddHostHidden}
|
||||
form={addHostForm}
|
||||
setForm={setAddHostForm}
|
||||
handleAddHost={handleAddHost}
|
||||
setIsAddHostHidden={setIsAddHostHidden}
|
||||
/>
|
||||
<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>
|
||||
</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 io from "socket.io-client";
|
||||
import PropTypes from "prop-types";
|
||||
import theme from "./theme";
|
||||
import theme from "../../theme.js";
|
||||
|
||||
export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => {
|
||||
export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidden }, ref) => {
|
||||
const terminalRef = useRef(null);
|
||||
const socketRef = useRef(null);
|
||||
const fitAddon = useRef(new FitAddon());
|
||||
@@ -55,30 +55,61 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => {
|
||||
terminalInstance.current.loadAddon(fitAddon.current);
|
||||
terminalInstance.current.open(terminalRef.current);
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.current.fit();
|
||||
resizeTerminal();
|
||||
terminalInstance.current.focus();
|
||||
}, 50);
|
||||
|
||||
const socket = io(
|
||||
window.location.hostname === "localhost"
|
||||
? "http://localhost:8081"
|
||||
: "/",
|
||||
{
|
||||
path: "/socket.io",
|
||||
path: "/ssh.io/socket.io",
|
||||
transports: ["websocket", "polling"],
|
||||
}
|
||||
);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect_error", (error) => {
|
||||
terminalInstance.current.write(`\r\n*** Socket connection error: ${error.message} ***\r\n`);
|
||||
});
|
||||
|
||||
socket.on("connect_timeout", () => {
|
||||
terminalInstance.current.write(`\r\n*** Socket connection timeout ***\r\n`);
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
const isAuthError = err.toLowerCase().includes("authentication") || err.toLowerCase().includes("auth");
|
||||
if (isAuthError && !hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) {
|
||||
authModalShown = true;
|
||||
setIsNoAuthHidden(false);
|
||||
}
|
||||
terminalInstance.current.write(`\r\n*** Error: ${err} ***\r\n`);
|
||||
});
|
||||
|
||||
socket.on("connect", () => {
|
||||
fitAddon.current.fit();
|
||||
resizeTerminal();
|
||||
const { cols, rows } = terminalInstance.current;
|
||||
socket.emit("connectToHost", cols, rows, hostConfig);
|
||||
|
||||
if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim()) {
|
||||
setIsNoAuthHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sshConfig = {
|
||||
ip: hostConfig.ip,
|
||||
user: hostConfig.user,
|
||||
port: Number(hostConfig.port) || 22,
|
||||
password: hostConfig.password?.trim(),
|
||||
rsaKey: hostConfig.rsaKey?.trim()
|
||||
};
|
||||
|
||||
socket.emit("connectToHost", cols, rows, sshConfig);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.current.fit();
|
||||
resizeTerminal();
|
||||
terminalInstance.current.focus();
|
||||
}, 50);
|
||||
|
||||
socket.on("data", (data) => {
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
terminalInstance.current.write(decoder.decode(new Uint8Array(data)));
|
||||
@@ -91,24 +122,41 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => {
|
||||
});
|
||||
|
||||
terminalInstance.current.attachCustomKeyEventHandler((event) => {
|
||||
console.log("Event caled");
|
||||
if (isPasting) return;
|
||||
|
||||
isPasting = true;
|
||||
setTimeout(() => {
|
||||
isPasting = false;
|
||||
}, 200);
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "v") {
|
||||
if (isPasting) return false;
|
||||
isPasting = true;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
socketRef.current.emit("data", text);
|
||||
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const lines = text.split("\n");
|
||||
|
||||
if (socketRef.current) {
|
||||
let index = 0;
|
||||
|
||||
const sendLine = () => {
|
||||
if (index < lines.length) {
|
||||
socketRef.current.emit("data", lines[index] + "\r");
|
||||
index++;
|
||||
setTimeout(sendLine, 10);
|
||||
} else {
|
||||
isPasting = false;
|
||||
}
|
||||
};
|
||||
|
||||
sendLine();
|
||||
} else {
|
||||
isPasting = false;
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error("Failed to read clipboard contents:", err);
|
||||
isPasting = false;
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -121,13 +169,25 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
terminalInstance.current.write(`\r\n*** Error: ${err} ***\r\n`);
|
||||
let authModalShown = false;
|
||||
|
||||
socket.on("noAuthRequired", () => {
|
||||
if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) {
|
||||
authModalShown = true;
|
||||
setIsNoAuthHidden(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
terminalInstance.current.dispose();
|
||||
socket.disconnect();
|
||||
if (terminalInstance.current) {
|
||||
terminalInstance.current.dispose();
|
||||
terminalInstance.current = null;
|
||||
}
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
authModalShown = false;
|
||||
};
|
||||
}, [hostConfig]);
|
||||
|
||||
@@ -174,8 +234,10 @@ NewTerminal.propTypes = {
|
||||
hostConfig: PropTypes.shape({
|
||||
ip: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
password: PropTypes.string.isRequired,
|
||||
port: PropTypes.string.isRequired,
|
||||
password: PropTypes.string,
|
||||
rsaKey: PropTypes.string,
|
||||
port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
}).isRequired,
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
};
|
||||
setIsNoAuthHidden: PropTypes.func.isRequired,
|
||||
};
|
||||
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 io = socketIo(server, {
|
||||
path: "/ssh.io/socket.io",
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"],
|
||||
@@ -12,77 +13,84 @@ const io = socketIo(server, {
|
||||
allowEIO3: true
|
||||
});
|
||||
|
||||
const logger = {
|
||||
info: (...args) => console.log(`🔧 [${new Date().toISOString()}] INFO:`, ...args),
|
||||
error: (...args) => console.error(`❌ [${new Date().toISOString()}] ERROR:`, ...args),
|
||||
warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args),
|
||||
debug: (...args) => console.debug(`🔍 [${new Date().toISOString()}] DEBUG:`, ...args)
|
||||
};
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("New socket connection established");
|
||||
logger.info("New socket connection established");
|
||||
|
||||
let stream = null;
|
||||
|
||||
socket.on("connectToHost", (cols, rows, hostConfig) => {
|
||||
if (!hostConfig || !hostConfig.ip || !hostConfig.user || (!hostConfig.password && !hostConfig.rsaKey) || !hostConfig.port) {
|
||||
console.error("Invalid hostConfig received:", hostConfig);
|
||||
if (!hostConfig || !hostConfig.ip || !hostConfig.user || !hostConfig.port) {
|
||||
logger.error("Invalid hostConfig received - missing required fields:", hostConfig);
|
||||
socket.emit("error", "Missing required connection details (IP, user, or port)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.password && !hostConfig.rsaKey) {
|
||||
logger.error("No authentication provided");
|
||||
socket.emit("error", "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Redact only sensitive info for logging
|
||||
const safeHostConfig = {
|
||||
ip: hostConfig.ip,
|
||||
port: hostConfig.port,
|
||||
user: hostConfig.user,
|
||||
password: hostConfig.password ? '***REDACTED***' : undefined,
|
||||
rsaKey: hostConfig.rsaKey ? '***REDACTED***' : undefined,
|
||||
authType: hostConfig.password ? 'password' : 'public key',
|
||||
};
|
||||
|
||||
console.log("Received hostConfig:", safeHostConfig);
|
||||
logger.info("Connecting with config:", safeHostConfig);
|
||||
const { ip, port, user, password, rsaKey } = hostConfig;
|
||||
|
||||
const conn = new SSHClient();
|
||||
conn
|
||||
.on("ready", function () {
|
||||
console.log("SSH connection established");
|
||||
logger.info("SSH connection established");
|
||||
|
||||
conn.shell({ term: "xterm-256color" }, function (err, newStream) {
|
||||
if (err) {
|
||||
console.error("Error:", err.message);
|
||||
logger.error("Shell error:", err.message);
|
||||
socket.emit("error", err.message);
|
||||
return;
|
||||
}
|
||||
stream = newStream;
|
||||
|
||||
// Set initial terminal size
|
||||
stream.setWindow(rows, cols, rows * 100, cols * 100);
|
||||
|
||||
// Pipe SSH output to client
|
||||
stream.on("data", function (data) {
|
||||
socket.emit("data", data);
|
||||
});
|
||||
|
||||
stream.on("close", function () {
|
||||
console.log("SSH stream closed");
|
||||
logger.info("SSH stream closed");
|
||||
conn.end();
|
||||
});
|
||||
|
||||
// Send keystrokes from terminal to SSH
|
||||
socket.on("data", function (data) {
|
||||
stream.write(data);
|
||||
});
|
||||
|
||||
// Resize SSH terminal when client resizes
|
||||
socket.on("resize", ({ cols, rows }) => {
|
||||
if (stream && stream.setWindow) {
|
||||
stream.setWindow(rows, cols, rows * 100, cols * 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-send initial terminal size to backend
|
||||
socket.emit("resize", { cols, rows });
|
||||
});
|
||||
})
|
||||
.on("close", function () {
|
||||
console.log("SSH connection closed");
|
||||
logger.info("SSH connection closed");
|
||||
socket.emit("error", "SSH connection closed");
|
||||
})
|
||||
.on("error", function (err) {
|
||||
console.error("Error:", err.message);
|
||||
logger.error("Error:", err.message);
|
||||
socket.emit("error", err.message);
|
||||
})
|
||||
.connect({
|
||||
@@ -95,10 +103,10 @@ io.on("connection", (socket) => {
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("Client disconnected");
|
||||
logger.info("Client disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(8081, '0.0.0.0', () => {
|
||||
console.log("Server is running on port 8081");
|
||||
logger.info("Server is running on port 8081");
|
||||
});
|
||||
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 './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<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/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ["**/docker/**"],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user