* Added user system with database features. This is fairly experimental and does not include dockerfile to automatically generate a mongodb. This should be in future commits along with ability to save hosts branching off this database feature.

* Updated README, fixed a few bugs with user creation, and added docker support to run MongoDB (needs testing)

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in connecting to sockets

* Update README.md

* Changes to connection system to support docker

* Changes to connection system to support docker

* Changes to connection system to support docker

* Changes to connection system to support docker

* Save hosts to tabs (very early version, not that many issues not just not very feature rich and has a poor UI that will be improved with more features)

* Updated launchpad UI to be expandable in the future. Updated UI for the hosts to be able to easily configure them. They stil need organizational system (folders, etc.)

* Better encryption for everything, new session login, rewrote a lot of database code changing its storage methods. Prepared for release of 2.0.

* Updated database connection method.

* Updated Profile modal to show username text more clearly

* Updated Profile modal to show username text more clearly

* Fixed control v pasting formating. Reorganized location of scripts. Visbile password and confirm password. Guest login. OpenSSH key authentication. Optional to remember password. Serach for host viewer.

* Waits for user to be able to log in. Improved UI for profile, edit and add host, and added organizational features to the host app (Folders, search, etc.)

* Updated various names for rsa keys to public keys, fixes ssh not connecting, better timing for editing host.

* Added ability to share hosts. Fixed up overall UI errors in console and cleaned up code for release.

* Fix GitHub build errors

* Attempt #1 to auto compile MongoDB into the build to exclude it from the compose.

* Attempt #2 to auto compile MongoDB into the build to exclude it from the compose.

* Attempt #3 to auto compile MongoDB into the build to exclude it from the compose.

* Attempt #3 to auto compile MongoDB into the build to exclude it from the compose.
This commit was merged in pull request #23.
This commit is contained in:
Karmaa
2025-03-16 14:17:55 -05:00
committed by GitHub
parent 9aa83c24ed
commit 10bc491a9f
33 changed files with 4820 additions and 464 deletions

View File

@@ -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
View File

@@ -157,4 +157,8 @@ typings/
.dotnet/
# .local
.local/
.local/
/docker/docker-compose.yml
/src/data/
/docker/mongodb/
/docker/docker-compose.yml

View File

@@ -8,8 +8,11 @@
[![Javascript Badge](https://img.shields.io/badge/-Javascript-F0DB4F?style=flat-square&labelColor=black&logo=javascript&logoColor=F0DB4F)](#)
[![Nodejs Badge](https://img.shields.io/badge/-Nodejs-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
[![HTML Badge](https://img.shields.io/badge/-HTML-E34F26?style=flat-square&labelColor=black&logo=html5&logoColor=E34F26)](#)
[![CSS Badge](https://img.shields.io/badge/-CSS-1572B6?style=flat-square&labelColor=black&logo=css3&logoColor=1572B6)](#)
[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#)
[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#)
[![MongoDB Badge](https://img.shields.io/badge/-MongoDB-47A248?style=flat-square&labelColor=black&logo=mongodb&logoColor=47A248)](#)
[![MUI Joy Badge](https://img.shields.io/badge/-MUI%20Joy-007FFF?style=flat-square&labelColor=black&logo=mui&logoColor=007FFF)](#)
<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
![Demo Image](repo-images/DemoImage1.png)
![Demo Image](repo-images/DemoImage2.png)
# License
Distributed under the MIT license. See LICENSE for more information.

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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
View 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
View 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;

View File

@@ -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
View 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
View 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');
});

View File

@@ -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");
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
src/images/profile_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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
View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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,
};

View 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;

View File

@@ -5,4 +5,10 @@ import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
watch: {
ignored: ["**/docker/**"],
},
},
})