From 133262a6129676268113d8632c11f5539a6906ce Mon Sep 17 00:00:00 2001 From: Karmaa Date: Sun, 16 Mar 2025 12:52:47 -0500 Subject: [PATCH] Added ability to share hosts. Fixed up overall UI errors in console and cleaned up code for release. --- README.md | 6 +- src/App.jsx | 12 +-- src/apps/Launchpad.jsx | 22 +++- src/apps/ssh/HostViewer.jsx | 151 ++++++++++++++++++++------- src/apps/ssh/Terminal.jsx | 6 +- src/apps/user/User.jsx | 21 ++++ src/backend/database.cjs | 58 ++++++++-- src/backend/ssh.cjs | 1 - src/modals/AddHostModal.jsx | 9 +- src/modals/EditHostModal.jsx | 2 +- src/modals/NoAuthenticationModal.jsx | 1 - src/modals/ProfileModal.jsx | 4 +- src/modals/ShareHostModal.jsx | 123 ++++++++++++++++++++++ 13 files changed, 341 insertions(+), 75 deletions(-) create mode 100644 src/modals/ShareHostModal.jsx diff --git a/README.md b/README.md index 01c893f1..d6fd87ed 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,9 @@ Termix is an open-source forever free self-hosted SSH (other protocols planned, - SSH - Split Screen (Up to 4) & Tab System - User Authentication -- Data Persistence +- Save Hosts (and easily view, connect, and manage them) # Planned Features -- Organize Hosts (folders, tags, etc.) - VNC - RDP - SFTP (build in file transfer) @@ -46,8 +45,7 @@ Termix is an open-source forever free self-hosted SSH (other protocols planned, - User Management (roles, permissions, etc.) - SSH Tunneling - More Authentication Methods -- Share Hosts -- More Security Features (like 2FA, optionally store host passwords, etc.) +- 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). diff --git a/src/App.jsx b/src/App.jsx index bd46ed15..cd6caadf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -223,14 +223,12 @@ function App() { const handleAddHost = () => { if (addHostForm.ip && addHostForm.user && addHostForm.port) { - // If not remembering the host, just connect without auth validation if (!addHostForm.rememberHost) { connectToHost(); setIsAddHostHidden(true); return; } - // If remembering the host, validate auth method if (addHostForm.authMethod === 'Select Auth') { alert("Please select an authentication method."); return; @@ -244,7 +242,6 @@ function App() { return; } - // Connect and save if all validation passes connectToHost(); if (!addHostForm.storePassword) { addHostForm.password = ''; @@ -257,7 +254,6 @@ function App() { }; const connectToHost = () => { - // Create a clean host config object const hostConfig = { name: addHostForm.name || '', folder: addHostForm.folder || '', @@ -300,17 +296,14 @@ function App() { }; const connectToHostWithConfig = (hostConfig) => { - // Ensure we have a valid host config if (!hostConfig || typeof hostConfig !== 'object') { return; } - // Ensure all required fields are present if (!hostConfig.ip || !hostConfig.user) { return; } - // Create a clean host config object with all required fields const cleanHostConfig = { name: hostConfig.name || '', folder: hostConfig.folder || '', @@ -459,8 +452,7 @@ function App() { oldHostConfig: oldConfig, newHostConfig: newConfig, }); - - // Keep the modal visible during the delay + await new Promise(resolve => setTimeout(resolve, 3000)); } finally { setIsEditing(false); @@ -700,6 +692,8 @@ function App() { isErrorHidden={isErrorHidden} deleteHost={deleteHost} editHost={handleEditHost} + shareHost={(hostId, username) => userRef.current?.shareHost(hostId, username)} + userRef={userRef} /> )} diff --git a/src/apps/Launchpad.jsx b/src/apps/Launchpad.jsx index 90dfcd83..c334c6b6 100644 --- a/src/apps/Launchpad.jsx +++ b/src/apps/Launchpad.jsx @@ -16,10 +16,13 @@ function Launchpad({ 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) => { @@ -28,7 +31,8 @@ function Launchpad({ !launchpadRef.current.contains(event.target) && isAddHostHidden && isEditHostHidden && - isErrorHidden + isErrorHidden && + !isAnyModalOpen ) { onClose(); } @@ -39,7 +43,15 @@ function Launchpad({ return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden]); + }, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden, isAnyModalOpen]); + + const handleModalOpen = () => { + setIsAnyModalOpen(true); + }; + + const handleModalClose = () => { + setIsAnyModalOpen(false); + }; return ( @@ -174,6 +186,10 @@ function Launchpad({ deleteHost={deleteHost} editHost={editHost} openEditPanel={editHost} + shareHost={shareHost} + onModalOpen={handleModalOpen} + onModalClose={handleModalClose} + userRef={userRef} /> )} @@ -193,6 +209,8 @@ Launchpad.propTypes = { isErrorHidden: PropTypes.bool.isRequired, deleteHost: PropTypes.func.isRequired, editHost: PropTypes.func.isRequired, + shareHost: PropTypes.func.isRequired, + userRef: PropTypes.object.isRequired, }; export default Launchpad; \ No newline at end of file diff --git a/src/apps/ssh/HostViewer.jsx b/src/apps/ssh/HostViewer.jsx index b370e65b..de84fbea 100644 --- a/src/apps/ssh/HostViewer.jsx +++ b/src/apps/ssh/HostViewer.jsx @@ -1,8 +1,9 @@ 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 }) { +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); @@ -12,6 +13,8 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e 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 { @@ -55,6 +58,14 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e setFilteredHosts(filtered); }, [searchTerm, hosts]); + useEffect(() => { + if (!isShareModalHidden) { + onModalOpen(); + } else { + onModalClose(); + } + }, [isShareModalHidden, onModalOpen, onModalClose]); + const toggleFolder = (folderName) => { setCollapsedFolders(prev => { const newSet = new Set(prev); @@ -160,18 +171,33 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e setIsDeleting(true); try { - await deleteHost({ _id: hostWrapper._id }); - await new Promise(resolve => setTimeout(resolve, 500)); // Add a small delay for UX + 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 host:', 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; @@ -181,14 +207,21 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
handleDragStart(e, hostWrapper)} + draggable={isOwner} + onDragStart={(e) => isOwner && handleDragStart(e, hostWrapper)} onDragEnd={() => setDraggedHost(null)} >
⋮⋮
-

{hostConfig.name || hostConfig.ip}

+
+

{hostConfig.name || hostConfig.ip}

+ {!isOwner && ( + + Shared by {hostWrapper.createdBy?.username} + + )} +

{hostConfig.user ? `${hostConfig.user}@${hostConfig.ip}` : `${hostConfig.ip}:${hostConfig.port}`}

@@ -214,35 +247,71 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e > Connect - - + {isOwner && ( + <> + + + + + )} + {!isOwner && ( + + )}
); @@ -328,6 +397,12 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e

No hosts available...

)}
+ ); } @@ -339,6 +414,10 @@ HostViewer.propTypes = { 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; \ No newline at end of file diff --git a/src/apps/ssh/Terminal.jsx b/src/apps/ssh/Terminal.jsx index 948826dd..b50f90f3 100644 --- a/src/apps/ssh/Terminal.jsx +++ b/src/apps/ssh/Terminal.jsx @@ -87,14 +87,12 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde fitAddon.current.fit(); resizeTerminal(); const { cols, rows } = terminalInstance.current; - - // Check if we have authentication + if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim()) { setIsNoAuthHidden(false); return; } - // Only connect if we have authentication const sshConfig = { ip: hostConfig.ip, user: hostConfig.user, @@ -106,7 +104,6 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde socket.emit("connectToHost", cols, rows, sshConfig); }); - // Fit and focus the terminal after it's opened setTimeout(() => { fitAddon.current.fit(); resizeTerminal(); @@ -175,7 +172,6 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidde let authModalShown = false; socket.on("noAuthRequired", () => { - // Only show auth modal if we don't have valid credentials if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) { authModalShown = true; setIsNoAuthHidden(false); diff --git a/src/apps/user/User.jsx b/src/apps/user/User.jsx index 23cf3644..b135a840 100644 --- a/src/apps/user/User.jsx +++ b/src/apps/user/User.jsx @@ -261,6 +261,26 @@ export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSucce } }; + 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, @@ -272,6 +292,7 @@ export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSucce deleteHost, shareHost, editHost, + removeShare, getUser: () => currentUser.current, })); diff --git a/src/backend/database.cjs b/src/backend/database.cjs index 8d2f7167..7561163f 100644 --- a/src/backend/database.cjs +++ b/src/backend/database.cjs @@ -230,13 +230,32 @@ io.of('/database.io').on('connection', (socket) => { return callback({ error: 'Invalid session' }); } - const hosts = await Host.find({ users: userId }); - const decryptedHosts = hosts.map(host => ({ - ...host.toObject(), - config: decryptData(host.config, userId, sessionToken) - })).filter(host => host.config); + 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; + } - callback({ success: true, hosts: decryptedHosts }); + 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' }); @@ -315,6 +334,33 @@ io.of('/database.io').on('connection', (socket) => { } }); + 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}`); diff --git a/src/backend/ssh.cjs b/src/backend/ssh.cjs index 56d3ff12..0fd3ba16 100644 --- a/src/backend/ssh.cjs +++ b/src/backend/ssh.cjs @@ -32,7 +32,6 @@ io.on("connection", (socket) => { return; } - // Require authentication if (!hostConfig.password && !hostConfig.rsaKey) { logger.error("No authentication provided"); socket.emit("error", "Authentication required"); diff --git a/src/modals/AddHostModal.jsx b/src/modals/AddHostModal.jsx index 2e2aec51..01880945 100644 --- a/src/modals/AddHostModal.jsx +++ b/src/modals/AddHostModal.jsx @@ -53,12 +53,10 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd }; const isFormValid = () => { - // Basic validation for required fields if (!form.ip || !form.user || !form.port) return false; const portNum = Number(form.port); if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false; - // Only validate auth method if rememberHost is true if (form.rememberHost) { if (form.authMethod === 'Select Auth') return false; if (form.authMethod === 'rsaKey' && !form.rsaKey) return false; @@ -71,15 +69,12 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd const handleSubmit = (event) => { event.preventDefault(); if (isFormValid()) { - // If not remembering the host, only send basic connection info if (!form.rememberHost) { handleAddHost(); } else { - // Only include auth details if remembering the host handleAddHost(); } - - // Reset form after successful submission + setForm({ name: '', folder: '', @@ -242,7 +237,7 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd onChange={(e) => setForm({ ...form, rememberHost: e.target.checked, - // Reset auth fields if unchecking remember host + ...((!e.target.checked) && { authMethod: 'Select Auth', password: '', diff --git a/src/modals/EditHostModal.jsx b/src/modals/EditHostModal.jsx index 21f87dca..048fb12d 100644 --- a/src/modals/EditHostModal.jsx +++ b/src/modals/EditHostModal.jsx @@ -41,7 +41,7 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH port: hostConfig.port || 22, authMethod: hostConfig.password ? 'password' : hostConfig.rsaKey ? 'rsaKey' : 'Select Auth', rememberHost: true, - storePassword: true, + storePassword: !!(hostConfig.password || hostConfig.rsaKey), }); } }, [isHidden, hostConfig]); diff --git a/src/modals/NoAuthenticationModal.jsx b/src/modals/NoAuthenticationModal.jsx index a08feede..6829aaf0 100644 --- a/src/modals/NoAuthenticationModal.jsx +++ b/src/modals/NoAuthenticationModal.jsx @@ -22,7 +22,6 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff'; const NoAuthenticationModal = ({ isHidden, form, setForm, setIsNoAuthHidden, handleAuthSubmit }) => { const [showPassword, setShowPassword] = useState(false); - // Initialize form with default values if not provided useEffect(() => { if (!form.authMethod) { setForm(prev => ({ diff --git a/src/modals/ProfileModal.jsx b/src/modals/ProfileModal.jsx index d572fb26..4b69d391 100644 --- a/src/modals/ProfileModal.jsx +++ b/src/modals/ProfileModal.jsx @@ -1,13 +1,11 @@ import PropTypes from 'prop-types'; -import { Modal, Typography, Button } from "@mui/joy"; +import { Modal, Button } from "@mui/joy"; import LogoutIcon from "@mui/icons-material/Logout"; import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; -import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import theme from "../theme"; export default function ProfileModal({ isHidden, - getUser, handleDeleteUser, handleLogoutUser, setIsProfileHidden, diff --git a/src/modals/ShareHostModal.jsx b/src/modals/ShareHostModal.jsx new file mode 100644 index 00000000..24e4be3d --- /dev/null +++ b/src/modals/ShareHostModal.jsx @@ -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 ( + + !isLoading && setIsHidden(true)} + sx={{ + position: 'fixed', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backdropFilter: 'blur(5px)', + backgroundColor: 'rgba(0, 0, 0, 0.2)', + }} + > + + Share Host + +
e.stopPropagation()}> + + Username to share with + setUsername(e.target.value)} + placeholder="Enter username" + onClick={(e) => e.stopPropagation()} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + mb: 2 + }} + /> + + + +
+
+
+
+
+ ); +}; + +ShareHostModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + setIsHidden: PropTypes.func.isRequired, + handleShare: PropTypes.func.isRequired, + hostConfig: PropTypes.object +}; + +export default ShareHostModal; \ No newline at end of file