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

This commit is contained in:
Karmaa
2025-03-16 01:51:14 -05:00
parent fd966b9954
commit fda8c7ce4b
7 changed files with 1162 additions and 692 deletions

View File

@@ -31,6 +31,7 @@ function App() {
const [nextId, setNextId] = useState(1); const [nextId, setNextId] = useState(1);
const [addHostForm, setAddHostForm] = useState({ const [addHostForm, setAddHostForm] = useState({
name: "", name: "",
folder: "",
ip: "", ip: "",
user: "", user: "",
password: "", password: "",
@@ -41,6 +42,7 @@ function App() {
}); });
const [editHostForm, setEditHostForm] = useState({ const [editHostForm, setEditHostForm] = useState({
name: "", name: "",
folder: "",
ip: "", ip: "",
user: "", user: "",
password: "", password: "",
@@ -66,6 +68,7 @@ function App() {
const [splitTabIds, setSplitTabIds] = useState([]); const [splitTabIds, setSplitTabIds] = useState([]);
const [isEditHostHidden, setIsEditHostHidden] = useState(true); const [isEditHostHidden, setIsEditHostHidden] = useState(true);
const [currentHostConfig, setCurrentHostConfig] = useState(null); const [currentHostConfig, setCurrentHostConfig] = useState(null);
const [isLoggingIn, setIsLoggingIn] = useState(true);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
@@ -124,23 +127,97 @@ function App() {
useEffect(() => { useEffect(() => {
const sessionToken = localStorage.getItem('sessionToken'); const sessionToken = localStorage.getItem('sessionToken');
if (sessionToken) { let isComponentMounted = true;
setTimeout(() => { let isLoginInProgress = false;
handleLoginUser({
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, sessionToken,
onSuccess: () => { onSuccess: () => {
setIsLoginUserHidden(true); if (isComponentMounted) {
clearTimeout(loginTimeout);
clearInterval(attemptLoginInterval);
setIsLoginUserHidden(true);
setIsLoggingIn(false);
setIsErrorHidden(true);
}
isLoginInProgress = false;
}, },
onFailure: (error) => { onFailure: (error) => {
setErrorMessage(`Auto-login failed: ${error}`); if (isComponentMounted) {
setIsErrorHidden(false); if (!userRef.current?.getUser()) {
setIsLoginUserHidden(false); clearTimeout(loginTimeout);
clearInterval(attemptLoginInterval);
localStorage.removeItem('sessionToken');
setErrorMessage(`Auto-login failed: ${error}`);
setIsErrorHidden(false);
setIsLoginUserHidden(false);
setIsLoggingIn(false);
}
}
isLoginInProgress = false;
}, },
}); });
}, 500); }
} else { loginAttempts++;
setIsLoginUserHidden(false); };
}
attemptLoginInterval = setInterval(attemptLogin, 100);
attemptLogin();
return () => {
isComponentMounted = false;
clearTimeout(loginTimeout);
clearInterval(attemptLoginInterval);
};
}, []); }, []);
const handleAddHost = () => { const handleAddHost = () => {
@@ -168,6 +245,8 @@ function App() {
id: nextId, id: nextId,
title: addHostForm.name || addHostForm.ip, title: addHostForm.name || addHostForm.ip,
hostConfig: { hostConfig: {
name: addHostForm.name,
folder: addHostForm.folder,
ip: addHostForm.ip, ip: addHostForm.ip,
user: addHostForm.user, user: addHostForm.user,
password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined, password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined,
@@ -180,7 +259,7 @@ function App() {
setActiveTab(nextId); setActiveTab(nextId);
setNextId(nextId + 1); setNextId(nextId + 1);
setIsAddHostHidden(true); setIsAddHostHidden(true);
setAddHostForm({ name: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth" }); setAddHostForm({ name: "", folder: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth", rememberHost: false, storePassword: true });
} }
const handleAuthSubmit = (form) => { const handleAuthSubmit = (form) => {
@@ -217,6 +296,7 @@ function App() {
const handleSaveHost = () => { const handleSaveHost = () => {
let hostConfig = { let hostConfig = {
name: addHostForm.name || addHostForm.ip, name: addHostForm.name || addHostForm.ip,
folder: addHostForm.folder,
ip: addHostForm.ip, ip: addHostForm.ip,
user: addHostForm.user, user: addHostForm.user,
password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined, password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined,
@@ -235,15 +315,32 @@ function App() {
if (sessionToken) { if (sessionToken) {
userRef.current.loginUser({ userRef.current.loginUser({
sessionToken, sessionToken,
onSuccess, onSuccess: () => {
onFailure, setIsLoginUserHidden(true);
setIsLoggingIn(false);
if (onSuccess) onSuccess();
},
onFailure: (error) => {
localStorage.removeItem('sessionToken');
setIsLoginUserHidden(false);
setIsLoggingIn(false);
if (onFailure) onFailure(error);
},
}); });
} else { } else {
userRef.current.loginUser({ userRef.current.loginUser({
username, username,
password, password,
onSuccess, onSuccess: () => {
onFailure, setIsLoginUserHidden(true);
setIsLoggingIn(false);
if (onSuccess) onSuccess();
},
onFailure: (error) => {
setIsLoginUserHidden(false);
setIsLoggingIn(false);
if (onFailure) onFailure(error);
},
}); });
} }
} }
@@ -264,7 +361,7 @@ function App() {
onFailure, onFailure,
}); });
} }
} };
const handleDeleteUser = ({ onSuccess, onFailure }) => { const handleDeleteUser = ({ onSuccess, onFailure }) => {
if (userRef.current) { if (userRef.current) {
@@ -311,31 +408,21 @@ function App() {
} }
}; };
const handleEditHost = async () => { const handleEditHost = async (oldConfig, newConfig = null) => {
try { try {
// Only clear the password if switching to RSA or storePassword is false if (newConfig) {
if (editHostForm.authMethod === 'rsaKey') { await userRef.current.editHost({
editHostForm.password = ''; oldHostConfig: oldConfig,
} else if (!editHostForm.storePassword) { newHostConfig: newConfig,
editHostForm.password = ''; });
return;
} }
await userRef.current.editHost({ updateEditHostForm(oldConfig);
oldHostConfig: currentHostConfig,
newHostConfig: editHostForm,
});
// Refresh the updated config
const refreshedHosts = await userRef.current.getAllHosts();
const updated = refreshedHosts.find(
(h) => h.config.ip === editHostForm.ip && h.config.user === editHostForm.user
);
if (updated) {
setCurrentHostConfig(updated.config);
}
setIsEditHostHidden(true);
} catch (error) { } catch (error) {
alert('Edit failed: ' + error); console.error('Edit failed:', error);
setErrorMessage(`Edit failed: ${error}`);
setIsErrorHidden(false);
} }
}; };
@@ -398,88 +485,124 @@ function App() {
</div> </div>
</div> </div>
{/* Launchpad Button */} {/* Action Buttons */}
<Button <div className="flex gap-4">
onClick={() => setIsLaunchpadOpen(true)} {/* Launchpad Button */}
sx={{ <Button
backgroundColor: theme.palette.general.tertiary, disabled={isLoggingIn || !userRef.current?.getUser()}
"&:hover": { backgroundColor: theme.palette.general.secondary }, onClick={() => setIsLaunchpadOpen(true)}
flexShrink: 0, sx={{
height: "52px", backgroundColor: theme.palette.general.tertiary,
width: "52px", "&:hover": { backgroundColor: theme.palette.general.secondary },
padding: 0, flexShrink: 0,
}} height: "52px",
> width: "52px",
<img src={RocketIcon} alt="Launchpad" style={{ width: "70%", height: "70", objectFit: "contain" }} /> padding: 0,
</Button> 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 */} {/* Add Host Button */}
<Button <Button
onClick={() => setIsAddHostHidden(false)} disabled={isLoggingIn || !userRef.current?.getUser()}
sx={{ onClick={() => setIsAddHostHidden(false)}
backgroundColor: theme.palette.general.tertiary, sx={{
"&:hover": { backgroundColor: theme.palette.general.secondary }, backgroundColor: theme.palette.general.tertiary,
flexShrink: 0, "&:hover": { backgroundColor: theme.palette.general.secondary },
height: "52px", flexShrink: 0,
width: "52px", height: "52px",
fontSize: "3.5rem", width: "52px",
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
paddingTop: "2px", padding: 0,
}} opacity: (!userRef.current?.getUser() || isLoggingIn) ? 0.3 : 1,
> cursor: (!userRef.current?.getUser() || isLoggingIn) ? 'not-allowed' : 'pointer',
+ "&:disabled": {
</Button> opacity: 0.3,
backgroundColor: theme.palette.general.tertiary,
},
fontSize: "4rem",
fontWeight: "600",
lineHeight: "0",
paddingBottom: "8px",
}}
>
+
</Button>
{/* Profile Button */} {/* Profile Button */}
<Button <Button
onClick={() => setIsProfileHidden(false)} disabled={isLoggingIn}
sx={{ onClick={() => userRef.current?.getUser() ? setIsProfileHidden(false) : setIsLoginUserHidden(false)}
backgroundColor: theme.palette.general.tertiary, sx={{
"&:hover": { backgroundColor: theme.palette.general.secondary }, backgroundColor: theme.palette.general.tertiary,
flexShrink: 0, "&:hover": { backgroundColor: theme.palette.general.secondary },
height: "52px", flexShrink: 0,
width: "52px", height: "52px",
display: "flex", width: "52px",
justifyContent: "center", display: "flex",
alignItems: "center", justifyContent: "center",
padding: 0, alignItems: "center",
}} padding: 0,
> opacity: isLoggingIn ? 0.3 : 1,
<img cursor: isLoggingIn ? 'not-allowed' : 'pointer',
src={ProfileIcon} "&:disabled": {
alt="Profile" opacity: 0.3,
style={{ width: "70%", height: "70%", objectFit: "contain" }} backgroundColor: theme.palette.general.tertiary,
/> }
</Button> }}
>
<img
src={ProfileIcon}
alt="Profile"
style={{ width: "70%", height: "70%", objectFit: "contain" }}
/>
</Button>
</div>
</div> </div>
{/* Terminal Views */} {/* Terminal Views */}
<div className={`relative p-4 terminal-container ${getLayoutStyle()}`}> <div className={`relative p-4 terminal-container ${getLayoutStyle()}`}>
{terminals.map((terminal) => ( {userRef.current?.getUser() ? (
<div terminals.map((terminal) => (
key={terminal.id} <div
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} key={terminal.id}
hostConfig={terminal.hostConfig} className={`bg-neutral-800 rounded-lg overflow-hidden shadow-xl border-5 border-neutral-700 ${
isVisible={activeTab === terminal.id || splitTabIds.includes(terminal.id)} splitTabIds.includes(terminal.id) || activeTab === terminal.id ? "block" : "hidden"
setIsNoAuthHidden={setIsNoAuthHidden} } flex-1`}
ref={(ref) => { style={{
terminal.terminalRef = ref; 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> </div>
))} )}
<NoAuthenticationModal <NoAuthenticationModal
isHidden={isNoAuthHidden} isHidden={isNoAuthHidden}
form={authForm} form={authForm}
@@ -488,85 +611,108 @@ function App() {
handleAuthSubmit={handleAuthSubmit} handleAuthSubmit={handleAuthSubmit}
/> />
</div> </div>
</div>
{/* Modals */} {/* Modals */}
<AddHostModal {userRef.current?.getUser() && (
isHidden={isAddHostHidden} <>
form={addHostForm} <AddHostModal
setForm={setAddHostForm} isHidden={isAddHostHidden}
handleAddHost={handleAddHost} form={addHostForm}
setIsAddHostHidden={setIsAddHostHidden} setForm={setAddHostForm}
/> handleAddHost={handleAddHost}
<EditHostModal setIsAddHostHidden={setIsAddHostHidden}
isHidden={isEditHostHidden} />
form={editHostForm} <EditHostModal
setForm={setEditHostForm} isHidden={isEditHostHidden}
handleEditHost={handleEditHost} form={editHostForm}
setIsEditHostHidden={setIsEditHostHidden} setForm={setEditHostForm}
hostConfig={currentHostConfig} handleEditHost={handleEditHost}
/> setIsEditHostHidden={setIsEditHostHidden}
<CreateUserModal hostConfig={currentHostConfig}
isHidden={isCreateUserHidden} />
form={createUserForm} <ProfileModal
setForm={setCreateUserForm} isHidden={isProfileHidden}
handleCreateUser={handleCreateUser} getUser={getUser}
setIsCreateUserHidden={setIsCreateUserHidden} handleDeleteUser={handleDeleteUser}
setIsLoginUserHidden={setIsLoginUserHidden} handleLogoutUser={handleLogoutUser}
/> setIsProfileHidden={setIsProfileHidden}
<ProfileModal />
isHidden={isProfileHidden} {isLaunchpadOpen && (
getUser={getUser} <Launchpad
handleDeleteUser={handleDeleteUser} onClose={() => setIsLaunchpadOpen(false)}
handleLogoutUser={handleLogoutUser} getHosts={getHosts}
setIsProfileHidden={setIsProfileHidden} connectToHost={connectToHostWithConfig}
/> isAddHostHidden={isAddHostHidden}
<ErrorModal setIsAddHostHidden={setIsAddHostHidden}
isHidden={isErrorHidden} isEditHostHidden={isEditHostHidden}
errorMessage={errorMessage} isErrorHidden={isErrorHidden}
setIsErrorHidden={setIsErrorHidden} deleteHost={deleteHost}
/> editHost={handleEditHost}
{isLaunchpadOpen && ( />
<Launchpad )}
onClose={() => setIsLaunchpadOpen(false)} </>
getHosts={getHosts} )}
connectToHost={connectToHostWithConfig}
isAddHostHidden={isAddHostHidden} <ErrorModal
setIsAddHostHidden={setIsAddHostHidden} isHidden={isErrorHidden}
isEditHostHidden={isEditHostHidden} errorMessage={errorMessage}
isErrorHidden={isErrorHidden} setIsErrorHidden={setIsErrorHidden}
deleteHost={deleteHost}
editHost={updateEditHostForm}
/> />
)}
<LoginUserModal <LoginUserModal
isHidden={isLoginUserHidden} isHidden={isLoginUserHidden}
form={loginUserForm} form={loginUserForm}
setForm={setLoginUserForm} setForm={setLoginUserForm}
handleLoginUser={handleLoginUser} handleLoginUser={handleLoginUser}
handleGuestLogin={handleGuestLogin} handleGuestLogin={handleGuestLogin}
setIsLoginUserHidden={setIsLoginUserHidden} setIsLoginUserHidden={setIsLoginUserHidden}
setIsCreateUserHidden={setIsCreateUserHidden} setIsCreateUserHidden={setIsCreateUserHidden}
/> />
{/* User component */} <CreateUserModal
<User isHidden={isCreateUserHidden}
ref={userRef} form={createUserForm}
onLoginSuccess={() => setIsLoginUserHidden(true)} setForm={setCreateUserForm}
onCreateSuccess={() => { handleCreateUser={handleCreateUser}
setIsCreateUserHidden(true); setIsCreateUserHidden={setIsCreateUserHidden}
handleLoginUser({ username: createUserForm.username, password: createUserForm.password })} setIsLoginUserHidden={setIsLoginUserHidden}
} />
onDeleteSuccess={() => {
setIsProfileHidden(true); {/* User component */}
window.location.reload(); <User
}} ref={userRef}
onFailure={(error) => { onLoginSuccess={() => {
setErrorMessage(`Action failed: ${error}`); setIsLoginUserHidden(true);
setIsErrorHidden(false); setIsLoggingIn(false);
}} setIsErrorHidden(true);
/> }}
onCreateSuccess={() => {
setIsCreateUserHidden(true);
handleLoginUser({
username: createUserForm.username,
password: createUserForm.password,
onSuccess: () => {
setIsLoginUserHidden(true);
setIsLoggingIn(false);
setIsErrorHidden(true);
},
onFailure: (error) => {
setErrorMessage(`Login failed: ${error}`);
setIsErrorHidden(false);
}
});
}}
onDeleteSuccess={() => {
setIsProfileHidden(true);
window.location.reload();
}}
onFailure={(error) => {
setErrorMessage(`Action failed: ${error}`);
setIsErrorHidden(false);
setIsLoggingIn(false);
}}
/>
</div>
</div> </div>
</CssVarsProvider> </CssVarsProvider>
); );

View File

@@ -4,20 +4,19 @@ import { CssVarsProvider } from '@mui/joy/styles';
import { Button } from '@mui/joy'; import { Button } from '@mui/joy';
import HostViewerIcon from '../images/host_viewer_icon.png'; import HostViewerIcon from '../images/host_viewer_icon.png';
import theme from '../theme.js'; import theme from '../theme.js';
// Apps
import HostViewer from './ssh/HostViewer.jsx'; import HostViewer from './ssh/HostViewer.jsx';
function Launchpad({onClose, function Launchpad({
getHosts, onClose,
connectToHost, getHosts,
isAddHostHidden, connectToHost,
setIsAddHostHidden, isAddHostHidden,
isEditHostHidden, setIsAddHostHidden,
isErrorHidden, isEditHostHidden,
deleteHost, isErrorHidden,
editHost, deleteHost,
}) { editHost,
}) {
const launchpadRef = useRef(null); const launchpadRef = useRef(null);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [activeApp, setActiveApp] = useState('hostViewer'); const [activeApp, setActiveApp] = useState('hostViewer');
@@ -42,11 +41,6 @@ function Launchpad({onClose,
}; };
}, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden]); }, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden]);
const handleEditHostClick = () => {
setIsAddHostHidden(false);
setActiveApp('hostViewer');
};
return ( return (
<CssVarsProvider theme={theme}> <CssVarsProvider theme={theme}>
<div <div
@@ -163,7 +157,7 @@ function Launchpad({onClose,
</div> </div>
{/* Main Content */} {/* Main Content */}
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}> <div style={{ flex: 1, overflow: 'hidden' }}>
{activeApp === 'hostViewer' && ( {activeApp === 'hostViewer' && (
<HostViewer <HostViewer
getHosts={getHosts} getHosts={getHosts}
@@ -171,7 +165,7 @@ function Launchpad({onClose,
setIsAddHostHidden={setIsAddHostHidden} setIsAddHostHidden={setIsAddHostHidden}
deleteHost={deleteHost} deleteHost={deleteHost}
editHost={editHost} editHost={editHost}
onEditHostClick={handleEditHostClick} openEditPanel={editHost}
/> />
)} )}
</div> </div>

View File

@@ -2,11 +2,14 @@ import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Button, Input } from "@mui/joy"; import { Button, Input } from "@mui/joy";
function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, editHost }) { function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, editHost, openEditPanel }) {
const [hosts, setHosts] = useState([]); const [hosts, setHosts] = useState([]);
const [filteredHosts, setFilteredHosts] = useState([]); const [filteredHosts, setFilteredHosts] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(""); 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 isMounted = useRef(true);
const fetchHosts = async () => { const fetchHosts = async () => {
@@ -44,11 +47,181 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
useEffect(() => { useEffect(() => {
const filtered = hosts.filter((hostWrapper) => { const filtered = hosts.filter((hostWrapper) => {
const hostConfig = hostWrapper.config || {}; const hostConfig = hostWrapper.config || {};
return hostConfig.name?.toLowerCase().includes(searchTerm.toLowerCase()) || hostConfig.ip?.toLowerCase().includes(searchTerm.toLowerCase()); return hostConfig.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
hostConfig.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
hostConfig.folder?.toLowerCase().includes(searchTerm.toLowerCase());
}); });
setFilteredHosts(filtered); setFilteredHosts(filtered);
}, [searchTerm, hosts]); }, [searchTerm, hosts]);
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 renderHostItem = (hostWrapper) => {
const hostConfig = hostWrapper.config || {};
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
onDragStart={(e) => 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>
<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();
connectToHost(hostConfig);
}}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" }
}}
>
Connect
</Button>
<Button
className="text-black"
onClick={(e) => {
e.stopPropagation();
deleteHost({ ...hostConfig, _id: hostWrapper._id });
}}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" }
}}
>
Delete
</Button>
<Button
className="text-black"
onClick={(e) => {
e.stopPropagation();
openEditPanel(hostConfig);
}}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" }
}}
>
Edit
</Button>
</div>
</div>
);
};
return ( return (
<div className="h-full w-full p-4 text-white flex flex-col"> <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"> <div className="flex items-center justify-between mb-2 w-full gap-2">
@@ -79,60 +252,51 @@ function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, e
<p className="text-gray-300">Loading hosts...</p> <p className="text-gray-300">Loading hosts...</p>
) : filteredHosts.length > 0 ? ( ) : filteredHosts.length > 0 ? (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{filteredHosts.map((hostWrapper, index) => { {(() => {
const hostConfig = hostWrapper.config || {}; const { grouped, sortedFolders, noFolder } = groupHostsByFolder(filteredHosts);
if (!hostConfig) {
return null;
}
return ( return (
<div key={index} className="flex justify-between items-center bg-neutral-800 p-3 rounded-lg shadow-md border border-neutral-700 w-full"> <>
<div> {/* Render hosts without folders first */}
<p className="font-semibold">{hostConfig.name || hostConfig.ip}</p> <div
<p className="text-sm text-gray-400"> className={`flex flex-col gap-2 p-2 rounded-lg transition-colors ${isDraggingOver === 'no-folder' ? 'bg-neutral-700' : ''}`}
{hostConfig.user ? `${hostConfig.user}@${hostConfig.ip}` : `${hostConfig.ip}:${hostConfig.port}`} onDragOver={(e) => handleDragOver(e, 'no-folder')}
</p> onDragLeave={handleDragLeave}
onDrop={handleDropOnNoFolder}
>
{noFolder.map((host) => renderHostItem(host))}
</div> </div>
<div className="flex gap-2">
<Button {/* Render folders and their hosts */}
className="text-black" {sortedFolders.map((folderName) => (
onClick={() => connectToHost(hostConfig)} <div key={folderName} className="mb-2">
sx={{ <div
backgroundColor: "#6e6e6e", className={`flex items-center gap-2 p-2 bg-neutral-600 rounded-lg cursor-pointer hover:bg-neutral-500 transition-colors ${
"&:hover": { backgroundColor: "#0f0f0f" } isDraggingOver === folderName ? 'bg-neutral-500 border-2 border-dashed border-neutral-400' : ''
}} }`}
> onClick={() => toggleFolder(folderName)}
Connect onDragOver={(e) => handleDragOver(e, folderName)}
</Button> onDragLeave={handleDragLeave}
<Button onDrop={(e) => handleDrop(e, folderName)}
className="text-black" >
onClick={() => { <span className={`font-bold w-4 text-center transition-transform ${collapsedFolders.has(folderName) ? 'rotate-[-90deg]' : ''}`}>
deleteHost({ ...hostConfig, _id: hostWrapper._id });
}} </span>
sx={{ <span className="font-bold">{folderName}</span>
backgroundColor: "#6e6e6e", <span className="text-sm text-gray-300">
"&:hover": { backgroundColor: "#0f0f0f" } ({grouped[folderName].length})
}} </span>
> </div>
Delete {!collapsedFolders.has(folderName) && (
</Button> <div className="ml-6 mt-2 flex flex-col gap-2">
<Button {grouped[folderName].map((host) => renderHostItem(host))}
className="text-black" </div>
onClick={() => { )}
editHost(hostConfig); </div>
}} ))}
sx={{ </>
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" }
}}
>
Edit
</Button>
</div>
</div>
); );
})} })()}
</div> </div>
) : ( ) : (
<p className="text-gray-300">No hosts available...</p> <p className="text-gray-300">No hosts available...</p>
@@ -148,6 +312,7 @@ HostViewer.propTypes = {
setIsAddHostHidden: PropTypes.func.isRequired, setIsAddHostHidden: PropTypes.func.isRequired,
deleteHost: PropTypes.func.isRequired, deleteHost: PropTypes.func.isRequired,
editHost: PropTypes.func.isRequired, editHost: PropTypes.func.isRequired,
openEditPanel: PropTypes.func.isRequired,
}; };
export default HostViewer; export default HostViewer;

View File

@@ -28,7 +28,8 @@ const hostSchema = new mongoose.Schema({
name: { type: String, required: true }, name: { type: String, required: true },
config: { type: String, required: true }, config: { type: String, required: true },
users: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], users: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
createdBy: { 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 User = mongoose.model('User', userSchema);
@@ -178,7 +179,8 @@ io.of('/database.io').on('connection', (socket) => {
} }
const cleanConfig = { const cleanConfig = {
name: hostConfig.name.trim(), name: hostConfig.name?.trim(),
folder: hostConfig.folder?.trim() || null,
ip: hostConfig.ip.trim(), ip: hostConfig.ip.trim(),
user: hostConfig.user.trim(), user: hostConfig.user.trim(),
port: hostConfig.port || 22, port: hostConfig.port || 22,
@@ -208,7 +210,8 @@ io.of('/database.io').on('connection', (socket) => {
name: finalName, name: finalName,
config: encryptedConfig, config: encryptedConfig,
users: [userId], users: [userId],
createdBy: userId createdBy: userId,
folder: cleanConfig.folder
}); });
logger.info(`Host created successfully: ${finalName}`); logger.info(`Host created successfully: ${finalName}`);
@@ -360,10 +363,11 @@ io.of('/database.io').on('connection', (socket) => {
} }
const cleanConfig = { const cleanConfig = {
name: newHostConfig.name?.trim(),
folder: newHostConfig.folder?.trim() || null,
ip: newHostConfig.ip.trim(), ip: newHostConfig.ip.trim(),
user: newHostConfig.user.trim(), user: newHostConfig.user.trim(),
port: newHostConfig.port || 22, port: newHostConfig.port || 22,
name: newHostConfig.name.trim(),
password: newHostConfig.password?.trim() || undefined, password: newHostConfig.password?.trim() || undefined,
rsaKey: newHostConfig.rsaKey?.trim() || undefined rsaKey: newHostConfig.rsaKey?.trim() || undefined
}; };
@@ -375,6 +379,7 @@ io.of('/database.io').on('connection', (socket) => {
} }
host.config = encryptedConfig; host.config = encryptedConfig;
host.folder = cleanConfig.folder;
await host.save(); await host.save();
logger.info(`Host edited successfully`); logger.info(`Host edited successfully`);

View File

@@ -13,7 +13,11 @@ import {
Select, Select,
Option, Option,
Checkbox, Checkbox,
IconButton IconButton,
Tabs,
TabList,
Tab,
TabPanel
} from '@mui/joy'; } from '@mui/joy';
import theme from '/src/theme'; import theme from '/src/theme';
import { useState } from 'react'; import { useState } from 'react';
@@ -22,6 +26,7 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff';
const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidden }) => { const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidden }) => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [activeTab, setActiveTab] = useState(0);
const handleFileChange = (e) => { const handleFileChange = (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
@@ -68,7 +73,6 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
<CssVarsProvider theme={theme}> <CssVarsProvider theme={theme}>
<Modal open={!isHidden} onClose={() => setIsAddHostHidden(true)} <Modal open={!isHidden} onClose={() => setIsAddHostHidden(true)}
sx={{ sx={{
overflow: 'hidden',
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -82,172 +86,258 @@ const AddHostModal = ({ isHidden, form, setForm, handleAddHost, setIsAddHostHidd
color: theme.palette.text.primary, color: theme.palette.text.primary,
padding: 3, padding: 3,
borderRadius: 10, borderRadius: 10,
maxWidth: '400px', maxWidth: '500px',
width: '100%', width: '100%',
overflow: 'hidden', maxHeight: '80vh',
overflow: 'auto',
boxSizing: 'border-box', boxSizing: 'border-box',
mx: 2, mx: 2,
}} }}
> >
<DialogTitle>Add Host</DialogTitle> <DialogTitle sx={{ mb: 2 }}>Add Host</DialogTitle>
<DialogContent> <DialogContent>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack spacing={2} sx={{ width: '100%' }}> <Tabs
<FormControl> value={activeTab}
<FormLabel>Host Name</FormLabel> onChange={(e, val) => setActiveTab(val)}
<Input sx={{
value={form.name} backgroundColor: theme.palette.general.disabled,
onChange={(e) => setForm({ ...form, name: e.target.value })} borderRadius: '8px',
sx={{ padding: '8px',
backgroundColor: theme.palette.general.primary, marginBottom: '16px',
color: theme.palette.text.primary, width: '100%',
}} }}
/> >
</FormControl> <TabList
<FormControl error={!form.ip}> sx={{
<FormLabel>Host IP</FormLabel> width: '100%',
<Input gap: 0,
value={form.ip} mb: 2,
onChange={(e) => setForm({ ...form, ip: e.target.value })} '& button': {
required flex: 1,
sx={{ bgcolor: 'transparent',
backgroundColor: theme.palette.general.primary, color: theme.palette.text.secondary,
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': { '&:hover': {
backgroundColor: theme.palette.general.disabled, bgcolor: 'rgba(255, 255, 255, 0.1)',
}, },
}} '&.Mui-selected': {
> bgcolor: theme.palette.general.primary,
<Option value="Select Auth" disabled> color: theme.palette.text.primary,
Select Auth '&:hover': {
</Option> bgcolor: theme.palette.general.primary,
<Option value="password">Password</Option> },
<Option value="rsaKey">RSA Key</Option> },
</Select> },
</FormControl> }}
{form.authMethod === 'password' && ( >
<FormControl error={!form.password}> <Tab>Basic Info</Tab>
<FormLabel>Host Password</FormLabel> <Tab>Connection</Tab>
<div style={{ display: 'flex', alignItems: 'center' }}> <Tab>Authentication</Tab>
</TabList>
<TabPanel value={0}>
<Stack spacing={2}>
<FormControl>
<FormLabel>Host Name</FormLabel>
<Input <Input
type={showPassword ? 'text' : 'password'} value={form.name}
value={form.password} onChange={(e) => setForm({ ...form, name: e.target.value })}
onChange={(e) => setForm({ ...form, password: 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 required
sx={{ sx={{
backgroundColor: theme.palette.general.primary, backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
flex: 1,
}} }}
/> />
<IconButton </FormControl>
onClick={() => setShowPassword(!showPassword)} <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 })}
sx={{ sx={{
color: theme.palette.text.primary, color: theme.palette.text.primary,
marginLeft: 1, '&.Mui-checked': {
color: theme.palette.text.primary,
},
}} }}
> />
{showPassword ? <VisibilityOff /> : <Visibility />} </FormControl>
</IconButton> {form.rememberHost && (
</div> <>
</FormControl> <FormControl>
)} <FormLabel>Store Password</FormLabel>
{form.authMethod === 'rsaKey' && ( <Checkbox
<FormControl error={!form.rsaKey}> checked={form.storePassword}
<FormLabel>RSA Key</FormLabel> onChange={(e) => setForm({ ...form, storePassword: e.target.checked })}
<Input sx={{
type="file" color: theme.palette.text.primary,
onChange={handleFileChange} '&.Mui-checked': {
required color: theme.palette.text.primary,
sx={{ },
backgroundColor: theme.palette.general.primary, }}
color: theme.palette.text.primary, />
padding: 1, </FormControl>
textAlign: 'center', <FormControl error={!form.authMethod || form.authMethod === 'Select Auth'}>
width: '100%', <FormLabel>Authentication Method</FormLabel>
}} <Select
/> value={form.authMethod || 'Select Auth'}
</FormControl> onChange={(e, newValue) => setForm({ ...form, authMethod: newValue })}
)} required
<FormControl error={form.port < 1 || form.port > 65535}> sx={{
<FormLabel>Host Port</FormLabel> backgroundColor: !form.authMethod || form.authMethod === 'Select Auth' ? theme.palette.general.tertiary : theme.palette.general.primary,
<Input color: theme.palette.text.primary,
value={form.port} '&:hover': {
onChange={(e) => setForm({ ...form, port: e.target.value })} backgroundColor: theme.palette.general.disabled,
min={1} },
max={65535} }}
required >
sx={{ <Option value="Select Auth" disabled>
backgroundColor: theme.palette.general.primary, Select Auth
color: theme.palette.text.primary, </Option>
}} <Option value="password">Password</Option>
/> <Option value="rsaKey">RSA Key</Option>
</FormControl> </Select>
<FormControl> </FormControl>
<FormLabel>Remember Host</FormLabel> {form.authMethod === 'password' && (
<Checkbox <FormControl error={!form.password}>
checked={form.rememberHost} <FormLabel>Host Password</FormLabel>
onChange={(e) => setForm({ ...form, rememberHost: e.target.checked })} <div style={{ display: 'flex', alignItems: 'center' }}>
sx={{ <Input
color: theme.palette.text.primary, type={showPassword ? 'text' : 'password'}
'&.Mui-checked': { value={form.password}
color: theme.palette.text.primary, onChange={(e) => setForm({ ...form, password: e.target.value })}
}, required
}} sx={{
/> backgroundColor: theme.palette.general.primary,
</FormControl> color: theme.palette.text.primary,
{form.rememberHost && ( flex: 1,
<FormControl> }}
<FormLabel>Store Password</FormLabel> />
<Checkbox <IconButton
checked={form.storePassword} onClick={() => setShowPassword(!showPassword)}
onChange={(e) => setForm({ ...form, storePassword: e.target.checked })} sx={{
sx={{ color: theme.palette.text.primary,
color: theme.palette.text.primary, marginLeft: 1,
'&.Mui-checked': { }}
color: theme.palette.text.primary, >
}, {showPassword ? <VisibilityOff /> : <Visibility />}
}} </IconButton>
/> </div>
</FormControl> </FormControl>
)} )}
<Button {form.authMethod === 'rsaKey' && (
type="submit" <FormControl error={!form.rsaKey}>
disabled={!isFormValid()} <FormLabel>RSA Key</FormLabel>
sx={{ <Button
backgroundColor: theme.palette.general.primary, component="label"
'&:hover': { sx={{
backgroundColor: theme.palette.general.disabled, backgroundColor: theme.palette.general.primary,
}, color: theme.palette.text.primary,
}} width: '100%',
> display: 'flex',
Add Host justifyContent: 'center',
</Button> alignItems: 'center',
</Stack> height: '40px',
'&:hover': {
backgroundColor: theme.palette.general.disabled,
},
}}
>
{form.rsaKey ? 'Change RSA Key File' : 'Upload RSA Key File'}
<Input
type="file"
onChange={handleFileChange}
required
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> </form>
</DialogContent> </DialogContent>
</ModalDialog> </ModalDialog>
@@ -260,6 +350,7 @@ AddHostModal.propTypes = {
isHidden: PropTypes.bool.isRequired, isHidden: PropTypes.bool.isRequired,
form: PropTypes.shape({ form: PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
folder: PropTypes.string,
ip: PropTypes.string.isRequired, ip: PropTypes.string.isRequired,
user: PropTypes.string.isRequired, user: PropTypes.string.isRequired,
password: PropTypes.string, password: PropTypes.string,

View File

@@ -14,7 +14,11 @@ import {
Select, Select,
Option, Option,
IconButton, IconButton,
Checkbox Checkbox,
Tabs,
TabList,
Tab,
TabPanel
} from '@mui/joy'; } from '@mui/joy';
import theme from '/src/theme'; import theme from '/src/theme';
import Visibility from '@mui/icons-material/Visibility'; import Visibility from '@mui/icons-material/Visibility';
@@ -22,25 +26,23 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff';
const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostHidden, hostConfig }) => { const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostHidden, hostConfig }) => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [activeTab, setActiveTab] = useState(0);
useEffect(() => { useEffect(() => {
if (hostConfig) { if (hostConfig && !isHidden) {
const storePassword = hostConfig.password || hostConfig.rsaKey;
setForm({ setForm({
...form, name: hostConfig.name || "",
name: hostConfig.name || '', folder: hostConfig.folder || "",
ip: hostConfig.ip || '', ip: hostConfig.ip || "",
user: hostConfig.user || '', user: hostConfig.user || "",
password: storePassword && hostConfig.password ? hostConfig.password : '', password: hostConfig.password || "",
rsaKey: '', port: hostConfig.port || 22,
port: Number(hostConfig.port) || 22, authMethod: hostConfig.password ? "password" : hostConfig.rsaKey ? "rsaKey" : "Select Auth",
authMethod: hostConfig.rsaKey ? 'rsaKey' : (storePassword ? 'password' : 'Select Auth'), rememberHost: true,
rememberHost: hostConfig.rememberHost || true, storePassword: true,
storePassword: storePassword ?? false
}); });
} }
}, [hostConfig, setForm]); }, [hostConfig, isHidden]);
const handleFileChange = (e) => { const handleFileChange = (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
@@ -65,7 +67,7 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
const handleStorePasswordChange = (checked) => { const handleStorePasswordChange = (checked) => {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
storePassword: checked, storePassword: Boolean(checked),
authMethod: checked ? 'password' : 'Select Auth' authMethod: checked ? 'password' : 'Select Auth'
})); }));
}; };
@@ -76,35 +78,42 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
const portNum = Number(port); const portNum = Number(port);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false; if (isNaN(portNum) || portNum < 1 || portNum > 65535) return false;
if (storePassword && authMethod === 'password' && !password.trim()) return false; if (Boolean(storePassword) && authMethod === 'password' && !password?.trim()) return false;
if (storePassword && authMethod === 'rsaKey' && !rsaKey && !hostConfig?.rsaKey) return false; if (Boolean(storePassword) && authMethod === 'rsaKey' && !rsaKey && !hostConfig?.rsaKey) return false;
if (storePassword && authMethod === 'Select Auth') return false; if (Boolean(storePassword) && authMethod === 'Select Auth') return false;
return true; return true;
}; };
const handleSubmit = (e) => { const handleSave = async (e) => {
e.preventDefault(); e.preventDefault();
if (isFormValid()) { try {
const { authMethod, password, rsaKey, storePassword, ...rest } = form; const newConfig = {
handleEditHost({ ...form,
...rest, port: String(form.port),
authMethod, };
password: authMethod === 'password' && storePassword ? password : '',
rsaKey: authMethod === 'rsaKey' ? rsaKey : '' if (form.authMethod === 'rsaKey' || !form.storePassword) {
}); newConfig.password = '';
}
await handleEditHost(hostConfig, newConfig);
setIsEditHostHidden(true);
} catch (error) {
console.error('Failed to save:', error);
} }
}; };
return ( return (
<CssVarsProvider theme={theme}> <CssVarsProvider theme={theme}>
<Modal open={!isHidden} onClose={() => setIsEditHostHidden(true)} <Modal
sx={{ open={!isHidden}
overflowX: 'hidden', onClose={() => setIsEditHostHidden(true)}
display: 'flex', sx={{
justifyContent: 'center', display: 'flex',
alignItems: 'center', justifyContent: 'center',
}} alignItems: 'center',
}}
> >
<ModalDialog <ModalDialog
layout="center" layout="center"
@@ -114,169 +123,249 @@ const EditHostModal = ({ isHidden, form, setForm, handleEditHost, setIsEditHostH
color: theme.palette.text.primary, color: theme.palette.text.primary,
padding: 3, padding: 3,
borderRadius: 10, borderRadius: 10,
maxWidth: '400px', maxWidth: '500px',
width: '100%', width: '100%',
overflow: 'hidden', maxHeight: '80vh',
overflow: 'auto',
boxSizing: 'border-box', boxSizing: 'border-box',
mx: 2, mx: 2,
}} }}
> >
<DialogTitle>Edit Host</DialogTitle> <DialogTitle sx={{ mb: 2 }}>Edit Host</DialogTitle>
<DialogContent> <DialogContent>
<form onSubmit={handleSubmit}> <form onSubmit={handleSave}>
<Stack spacing={2} sx={{ width: '100%', overflow: 'hidden' }}> <Tabs
<FormControl> value={activeTab}
<FormLabel>Host Name</FormLabel> onChange={(e, val) => setActiveTab(val)}
<Input sx={{
value={form.name} backgroundColor: theme.palette.general.disabled,
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} borderRadius: '8px',
sx={{ padding: '8px',
backgroundColor: theme.palette.general.primary, marginBottom: '16px',
color: theme.palette.text.primary width: '100%',
}} }}
/> >
</FormControl> <TabList
sx={{
<FormControl error={!form.ip}> width: '100%',
<FormLabel>Host IP</FormLabel> gap: 0,
<Input mb: 2,
value={form.ip} '& button': {
onChange={(e) => setForm((prev) => ({ ...prev, ip: e.target.value }))} flex: 1,
sx={{ bgcolor: 'transparent',
backgroundColor: theme.palette.general.primary, color: theme.palette.text.secondary,
color: theme.palette.text.primary '&:hover': {
}} bgcolor: 'rgba(255, 255, 255, 0.1)',
/> },
</FormControl> '&.Mui-selected': {
bgcolor: theme.palette.general.primary,
<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>
{form.storePassword && form.authMethod !== 'Select Auth' && (
<FormControl error={form.authMethod === 'Select Auth'}>
<FormLabel>Authentication Method</FormLabel>
<Select
value={form.authMethod}
onChange={(e, val) => handleAuthChange(val)}
sx={{
backgroundColor:
form.authMethod === 'Select Auth'
? theme.palette.general.tertiary
: theme.palette.general.primary,
color: theme.palette.text.primary, color: theme.palette.text.primary,
'&:hover': { '&:hover': {
backgroundColor: theme.palette.general.disabled bgcolor: theme.palette.general.primary,
} },
}} },
> },
<Option value="Select Auth" disabled>Select Auth</Option>
<Option value="password">Password</Option>
<Option value="rsaKey">RSA 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}
sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
>
<FormLabel>RSA Key</FormLabel>
<Input
type="file"
onChange={handleFileChange}
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary,
alignItems: 'center'
}}
/>
{hostConfig?.rsaKey && !form.rsaKey && (
<FormLabel sx={{ color: theme.palette.text.secondary }}>
Existing key detected. Upload to replace.
</FormLabel>
)}
</FormControl>
)}
<FormControl error={form.port < 1 || form.port > 65535}>
<FormLabel>Host Port</FormLabel>
<Input
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>
<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>
<Button
type="submit"
disabled={!isFormValid()}
sx={{
backgroundColor: theme.palette.general.primary,
color: theme.palette.text.primary,
'&:hover': {
backgroundColor: theme.palette.general.disabled
}
}} }}
> >
Save Changes <Tab>Basic Info</Tab>
</Button> <Tab>Connection</Tab>
</Stack> <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">RSA 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>RSA 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 RSA Key File' : 'Upload RSA 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()}
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',
}}
>
Save Changes
</Button>
</form> </form>
</DialogContent> </DialogContent>
</ModalDialog> </ModalDialog>

View File

@@ -1,101 +1,83 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { CssVarsProvider } from '@mui/joy/styles'; import { Modal, Typography, Button } from "@mui/joy";
import { Modal, Button, DialogTitle, DialogContent, ModalDialog, Stack } from '@mui/joy'; import LogoutIcon from "@mui/icons-material/Logout";
import theme from '/src/theme'; import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
const ProfileModal = ({ isHidden, getUser, handleDeleteUser, handleLogoutUser, setIsProfileHidden }) => { import theme from "../theme";
const handleDelete = () => {
handleDeleteUser({
onSuccess: () => {
window.location.reload();
}
});
};
const handleLogout = () => {
handleLogoutUser({
onSuccess: () => {
window.location.reload();
}
});
}
const getUserName = () => {
const user = getUser();
return user ? user.username : '';
}
export default function ProfileModal({
isHidden,
getUser,
handleDeleteUser,
handleLogoutUser,
setIsProfileHidden,
}) {
return ( return (
<CssVarsProvider theme={theme}> <Modal
<Modal open={!isHidden} onClose={() => setIsProfileHidden(true)}> open={!isHidden}
<ModalDialog onClose={() => setIsProfileHidden(true)}
layout="center" sx={{
sx={{ display: "flex",
backgroundColor: theme.palette.general.tertiary, justifyContent: "center",
borderColor: theme.palette.general.secondary, alignItems: "center",
color: theme.palette.text.primary, }}
padding: 3, >
borderRadius: 10, <div style={{
width: "auto", backgroundColor: theme.palette.general.tertiary,
maxWidth: "90vw", borderColor: theme.palette.general.secondary,
minWidth: "fit-content", borderWidth: "1px",
overflow: "hidden", borderStyle: "solid",
display: "flex", borderRadius: "0.5rem",
flexDirection: "column", width: "400px",
alignItems: "center", overflow: "hidden",
justifyContent: "center", }}>
gap: 1, <div className="p-4 flex flex-col gap-4">
}} <Button
> fullWidth
<DialogTitle onClick={handleLogoutUser}
startDecorator={<LogoutIcon />}
sx={{ sx={{
marginBottom: 1.5, backgroundColor: theme.palette.general.tertiary,
backgroundColor: theme.palette.general.primary, color: "white",
color: theme.palette.text.primary, "&:hover": {
padding: 1, backgroundColor: theme.palette.general.secondary,
borderRadius: 10, },
width: "100%", height: "40px",
textAlign: "center", border: `1px solid ${theme.palette.general.secondary}`,
display: "flex",
justifyContent: "center",
alignItems: "center",
}} }}
> >
User: {getUserName()} Logout
</DialogTitle> </Button>
<DialogContent sx={{ width: "100%" }}>
<Stack spacing={2} sx={{ width: "100%", maxWidth: "100%", overflow: "hidden", mt: 1.5 }}> <Button
<Button fullWidth
onClick={handleDelete} color="danger"
sx={{ onClick={() => {
backgroundColor: theme.palette.general.primary, if (window.confirm("Are you sure you want to delete your account? This action cannot be undone.")) {
'&:hover': { handleDeleteUser({
backgroundColor: theme.palette.general.disabled, onSuccess: () => setIsProfileHidden(true),
}, onFailure: (error) => console.error(error),
width: "100%", });
}} }
> }}
Delete User startDecorator={<DeleteForeverIcon />}
</Button> sx={{
<Button backgroundColor: "#c53030",
onClick={handleLogout} color: "white",
sx={{ "&:hover": {
backgroundColor: theme.palette.general.primary, backgroundColor: "#9b2c2c",
'&:hover': { },
backgroundColor: theme.palette.general.disabled, height: "40px",
}, border: "1px solid #9b2c2c",
width: "100%", }}
}} >
> Delete Account
Logout </Button>
</Button> </div>
</Stack> </div>
</DialogContent> </Modal>
</ModalDialog>
</Modal>
</CssVarsProvider>
); );
}; }
ProfileModal.propTypes = { ProfileModal.propTypes = {
isHidden: PropTypes.bool.isRequired, isHidden: PropTypes.bool.isRequired,
@@ -104,5 +86,3 @@ ProfileModal.propTypes = {
handleLogoutUser: PropTypes.func.isRequired, handleLogoutUser: PropTypes.func.isRequired,
setIsProfileHidden: PropTypes.func.isRequired, setIsProfileHidden: PropTypes.func.isRequired,
}; };
export default ProfileModal;