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

This commit is contained in:
Karmaa
2025-03-16 12:52:47 -05:00
parent bec8b67303
commit 133262a612
13 changed files with 341 additions and 75 deletions

View File

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

View File

@@ -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}
/>
)}
</>

View File

@@ -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 (
<CssVarsProvider theme={theme}>
@@ -174,6 +186,10 @@ function Launchpad({
deleteHost={deleteHost}
editHost={editHost}
openEditPanel={editHost}
shareHost={shareHost}
onModalOpen={handleModalOpen}
onModalClose={handleModalClose}
userRef={userRef}
/>
)}
</div>
@@ -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;

View File

@@ -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
<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
onDragStart={(e) => handleDragStart(e, hostWrapper)}
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>
<p className="font-semibold">{hostConfig.name || hostConfig.ip}</p>
<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>
@@ -214,35 +247,71 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
>
Connect
</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) => {
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>
);
@@ -328,6 +397,12 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
<p className="text-gray-300">No hosts available...</p>
)}
</div>
<ShareHostModal
isHidden={isShareModalHidden}
setIsHidden={setIsShareModalHidden}
handleShare={handleShare}
hostConfig={selectedHostForShare}
/>
</div>
);
}
@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '',

View File

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

View File

@@ -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 => ({

View File

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

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;