From 4e277bdd07958ac3304daeacf873731b486b3ac0 Mon Sep 17 00:00:00 2001 From: Karmaa Date: Mon, 10 Mar 2025 23:59:06 -0500 Subject: [PATCH] Added user system with database features. This is fairly experimental and does not include dockerfile to automatically generate a mongodb. This should be in future commits along with ability to save hosts branching off this database feature. --- .gitignore | 6 +- package-lock.json | 206 +++++++++++++++++++++++++++++++++++- package.json | 2 + src/App.jsx | 192 +++++++++++++++++++++++++++++---- src/CreateUserModal.jsx | 122 +++++++++++++++++++++ src/ErrorModal.jsx | 56 ++++++++++ src/LoginUserModal.jsx | 122 +++++++++++++++++++++ src/ProfileModal.jsx | 85 +++++++++++++++ src/Terminal.jsx | 1 - src/User.jsx | 129 ++++++++++++++++++++++ src/backend/database.cjs | 156 +++++++++++++++++++++++++++ src/images/profile_icon.png | Bin 0 -> 26728 bytes vite.config.js | 6 ++ 13 files changed, 1057 insertions(+), 26 deletions(-) create mode 100644 src/CreateUserModal.jsx create mode 100644 src/ErrorModal.jsx create mode 100644 src/LoginUserModal.jsx create mode 100644 src/ProfileModal.jsx create mode 100644 src/User.jsx create mode 100644 src/backend/database.cjs create mode 100644 src/images/profile_icon.png diff --git a/.gitignore b/.gitignore index 56bf7b8a..e1d6051b 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,8 @@ typings/ .dotnet/ # .local -.local/ \ No newline at end of file +.local/ +/docker/docker-compose.yml +/src/data/ +/docker/mongodb/ +/docker/docker-compose.yml diff --git a/package-lock.json b/package-lock.json index c1084feb..30417680 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,11 +20,13 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "cors": "^2.8.5", + "crypto": "^1.0.1", "dayjs": "^1.11.13", "embla-carousel-react": "^7.1.0", "express": "^4.21.2", "is-stream": "^4.0.1", "make-dir": "^5.0.0", + "mongoose": "^8.12.1", "node-ssh": "^13.2.0", "prop-types": "^15.8.1", "react": "^18.3.1", @@ -1200,6 +1202,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.0.tgz", + "integrity": "sha512-+ywrb0AqkfaYuhHs6LxKWgqbh3I72EpEgESCw37o+9qPx9WTCkgDm2B+eMrwehGtHBWHFU4GXvnSCNiFhhausg==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@mui/base": { "version": "5.0.0-beta.40-0", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40-0.tgz", @@ -2528,6 +2539,21 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", @@ -2958,6 +2984,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", + "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buildcheck": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", @@ -3223,6 +3258,13 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -5275,6 +5317,15 @@ "node": ">=4.0" } }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5652,6 +5703,12 @@ "node": ">= 0.6" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -5716,6 +5773,105 @@ "node": "*" } }, + "node_modules/mongodb": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz", + "integrity": "sha512-kMEHNo0F3P6QKDq17zcDuPeaywK/YaJVCEQRzPF3TOM/Bl9MFg64YE5Tu7ifj37qZJMhwU1tl2Ioivws5gRG5Q==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.3", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.12.1.tgz", + "integrity": "sha512-UW22y8QFVYmrb36hm8cGncfn4ARc/XsYWQwRTaj0gxtQk1rDuhzDO1eBantS+hTTatfAIS96LlRCJrcNHvW5+Q==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.3", + "kareem": "2.6.3", + "mongodb": "~6.14.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6362,7 +6518,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6986,6 +7141,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/socket.io": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", @@ -7128,6 +7289,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/ssh2": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", @@ -7335,6 +7505,18 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -7652,6 +7834,28 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", + "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index dbc72f7a..7674c153 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "cors": "^2.8.5", + "crypto": "^1.0.1", "dayjs": "^1.11.13", "embla-carousel-react": "^7.1.0", "express": "^4.21.2", "is-stream": "^4.0.1", "make-dir": "^5.0.0", + "mongoose": "^8.12.1", "node-ssh": "^13.2.0", "prop-types": "^15.8.1", "react": "^18.3.1", diff --git a/src/App.jsx b/src/App.jsx index 6c1a6474..eb34e7e6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,8 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { NewTerminal } from "./Terminal.jsx"; +import { User } from "./User.jsx"; import AddHostModal from "./AddHostModal.jsx"; +import LoginUserModal from "./LoginUserModal.jsx"; import { Button } from "@mui/joy"; import { CssVarsProvider } from "@mui/joy"; import theme from "./theme"; @@ -9,13 +11,23 @@ import Launchpad from "./Launchpad.jsx"; import { Debounce } from './Utils'; import TermixIcon from "./images/termix_icon.png"; import RocketIcon from './images/launchpad_rocket.png'; +import ProfileIcon from './images/profile_icon.png'; +import CreateUserModal from "./CreateUserModal.jsx"; +import ProfileModal from "./ProfileModal.jsx"; +import ErrorModal from "./ErrorModal.jsx"; function App() { const [isAddHostHidden, setIsAddHostHidden] = useState(true); + const [isLoginUserHidden, setIsLoginUserHidden] = useState(true); + const [isCreateUserHidden, setIsCreateUserHidden] = useState(true); + const [isProfileHidden, setIsProfileHidden] = useState(true); + const [isErrorHidden, setIsErrorHidden] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); const [terminals, setTerminals] = useState([]); + const userRef = useRef(null); const [activeTab, setActiveTab] = useState(null); const [nextId, setNextId] = useState(1); - const [form, setForm] = useState({ + const [addHostForm, setAddHostForm] = useState({ name: "", ip: "", user: "", @@ -23,6 +35,14 @@ function App() { port: 22, authMethod: "Select Auth", }); + const [loginUserForm, setLoginUserForm] = useState({ + username: "", + password: "", + }); + const [createUserForm, setCreateUserForm] = useState({ + username: "", + password: "", + }); const [isLaunchpadOpen, setIsLaunchpadOpen] = useState(false); const [splitTabIds, setSplitTabIds] = useState([]); @@ -81,17 +101,38 @@ function App() { }); }, [splitTabIds]); + useEffect(() => { + const sessionToken = localStorage.getItem('sessionToken'); + if (sessionToken) { + setTimeout(() => { + handleLoginUser({ + sessionToken, + onSuccess: () => { + setIsLoginUserHidden(true); + }, + onFailure: (error) => { + setErrorMessage(`Auto-login failed: ${error}`); + setIsErrorHidden(false); + setIsLoginUserHidden(false); + }, + }); + }, 500); + } else { + setIsLoginUserHidden(false); + } + }, []); + const handleAddHost = () => { - if (form.ip && form.user && ((form.authMethod === 'password' && form.password) || (form.authMethod === 'rsaKey' && form.rsaKey)) && form.port) { + if (addHostForm.ip && addHostForm.user && ((addHostForm.authMethod === 'password' && addHostForm.password) || (addHostForm.authMethod === 'rsaKey' && addHostForm.rsaKey)) && addHostForm.port) { const newTerminal = { id: nextId, - title: form.name || form.ip, + title: addHostForm.name || addHostForm.ip, hostConfig: { - ip: form.ip, - user: form.user, - password: form.authMethod === 'password' ? form.password : undefined, - rsaKey: form.authMethod === 'rsaKey' ? form.rsaKey : undefined, - port: String(form.port), + ip: addHostForm.ip, + user: addHostForm.user, + password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined, + rsaKey: addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined, + port: String(addHostForm.port), }, terminalRef: null, }; @@ -99,12 +140,58 @@ function App() { setActiveTab(nextId); setNextId(nextId + 1); setIsAddHostHidden(true); - setForm({ name: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth" }); + setAddHostForm({ name: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth" }); } else { alert("Please fill out all fields."); } }; + const handleLoginUser = ({ username, password, sessionToken, onSuccess, onFailure }) => { + if (userRef.current) { + if (sessionToken) { + userRef.current.loginUser({ + sessionToken, + onSuccess, + onFailure, + }); + } else { + userRef.current.loginUser({ + username, + password, + onSuccess, + onFailure, + }); + } + } + }; + + const handleCreateUser = ({ username, password, onSuccess, onFailure }) => { + if (userRef.current) { + userRef.current.createUser({ + username, + password, + onSuccess, + onFailure, + }); + } + } + + const handleDeleteUser = ({ onSuccess, onFailure }) => { + if (userRef.current) { + userRef.current.deleteUser({ + onSuccess, + onFailure, + }); + } + }; + + const handleLogoutUser = () => { + if (userRef.current) { + userRef.current.logoutUser(); + window.location.reload(); + } + }; + const closeTab = (id) => { const newTerminals = terminals.filter((t) => t.id !== id); setTerminals(newTerminals); @@ -197,6 +284,28 @@ function App() { > + + + {/* Profile Button */} + {/* Terminal Views */} @@ -209,10 +318,8 @@ function App() { } flex-1`} style={{ order: splitTabIds.includes(terminal.id) - ? splitTabIds.indexOf(terminal.id) + 1 - : activeTab === terminal.id - ? 0 - : undefined + ? splitTabIds.indexOf(terminal.id) + : 0, }} > { - if (ref && !terminal.terminalRef) { - setTerminals((prev) => - prev.map((t) => - t.id === terminal.id ? { ...t, terminalRef: ref } : t - ) - ); - } + terminal.terminalRef = ref; }} /> @@ -237,12 +338,57 @@ function App() { {/* Modals */} + + + + {isLaunchpadOpen && setIsLaunchpadOpen(false)} />} + + {/* User component */} + setIsLoginUserHidden(true)} + onCreateSuccess={() => { + setIsCreateUserHidden(true); + handleLoginUser({ username: createUserForm.username, password: createUserForm.password })} + } + onDeleteSuccess={() => { + setIsProfileHidden(true); + window.location.reload(); + }} + onFailure={(error) => { + setErrorMessage(`Action failed: ${error}`); + setIsErrorHidden(false); + }} + /> ); diff --git a/src/CreateUserModal.jsx b/src/CreateUserModal.jsx new file mode 100644 index 00000000..c9e6d797 --- /dev/null +++ b/src/CreateUserModal.jsx @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog } from '@mui/joy'; +import theme from './theme'; +import { useEffect } from 'react'; + +const CreateUserModal = ({ isHidden, form, setForm, handleCreateUser, setIsCreateUserHidden, setIsLoginUserHidden }) => { + const isFormValid = () => { + if (!form.username || !form.password) return false; + return true; + }; + + const handleCreate = () => { + handleCreateUser({ + ...form + }); + }; + + useEffect(() => { + if (isHidden) { + setForm({ username: '', password: '' }); + } + }, [isHidden]); + + return ( + + {}}> + + Create + +
{ + event.preventDefault(); + if (isFormValid()) handleCreate(); + }} + > + + + Username + setForm({ ...form, username: event.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Password + setForm({ ...form, password: event.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + + +
+
+
+
+
+ ); +}; + +CreateUserModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + form: PropTypes.object.isRequired, + setForm: PropTypes.func.isRequired, + handleCreateUser: PropTypes.func.isRequired, + setIsCreateUserHidden: PropTypes.func.isRequired, + setIsLoginUserHidden: PropTypes.func.isRequired, +}; + +export default CreateUserModal; \ No newline at end of file diff --git a/src/ErrorModal.jsx b/src/ErrorModal.jsx new file mode 100644 index 00000000..272b6675 --- /dev/null +++ b/src/ErrorModal.jsx @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { Modal, Button, DialogTitle, DialogContent, ModalDialog } from '@mui/joy'; +import theme from './theme'; + +const ErrorModal = ({ isHidden, errorMessage, setIsErrorHidden }) => { + return ( + + setIsErrorHidden(true)}> + + Error + + {errorMessage} + + + + + + ); +}; + +ErrorModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + errorMessage: PropTypes.string.isRequired, + setIsErrorHidden: PropTypes.func.isRequired, +}; + +export default ErrorModal; \ No newline at end of file diff --git a/src/LoginUserModal.jsx b/src/LoginUserModal.jsx new file mode 100644 index 00000000..44ec49bc --- /dev/null +++ b/src/LoginUserModal.jsx @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import { Modal, Button, FormControl, FormLabel, Input, Stack, DialogTitle, DialogContent, ModalDialog } from '@mui/joy'; +import theme from './theme'; +import {useEffect} from 'react'; + +const LoginUserModal = ({ isHidden, form, setForm, handleLoginUser, setIsLoginUserHidden, setIsCreateUserHidden }) => { + const isFormValid = () => { + if (!form.username || !form.password) return false; + return true; + }; + + const handleLogin = () => { + handleLoginUser({ + ...form, + }); + }; + + useEffect(() => { + if (isHidden) { + setForm({ username: '', password: '' }); + } + }, [isHidden]); + + return ( + + {}}> + + Login + +
{ + event.preventDefault(); + if (isFormValid()) handleLogin(); + }} + > + + + Username + setForm({ ...form, username: event.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + Password + setForm({ ...form, password: event.target.value })} + sx={{ + backgroundColor: theme.palette.general.primary, + color: theme.palette.text.primary, + }} + /> + + + + +
+
+
+
+
+ ); +}; + +LoginUserModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + form: PropTypes.object.isRequired, + setForm: PropTypes.func.isRequired, + handleLoginUser: PropTypes.func.isRequired, + setIsLoginUserHidden: PropTypes.func.isRequired, + setIsCreateUserHidden: PropTypes.func.isRequired, +}; + +export default LoginUserModal; \ No newline at end of file diff --git a/src/ProfileModal.jsx b/src/ProfileModal.jsx new file mode 100644 index 00000000..0910c35e --- /dev/null +++ b/src/ProfileModal.jsx @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import { CssVarsProvider } from '@mui/joy/styles'; +import {Modal, Button, DialogTitle, DialogContent, ModalDialog, Stack } from '@mui/joy'; +import theme from './theme'; + +const ProfileModal = ({ isHidden, handleDeleteUser, handleLogoutUser, setIsProfileHidden }) => { + const handleDelete = () => { + handleDeleteUser({ + onSuccess: () => { + window.location.reload(); + } + }); + }; + + const handleLogout = () => { + handleLogoutUser({ + onSuccess: () => { + window.location.reload(); + } + }); + } + + return ( + + setIsProfileHidden(true)}> + + Profile + + + + + + + + + + ); +}; + +ProfileModal.propTypes = { + isHidden: PropTypes.bool.isRequired, + handleDeleteUser: PropTypes.func.isRequired, + handleLogoutUser: PropTypes.func.isRequired, + setIsProfileHidden: PropTypes.func.isRequired, +}; + +export default ProfileModal; \ No newline at end of file diff --git a/src/Terminal.jsx b/src/Terminal.jsx index 11a259e4..4e205814 100644 --- a/src/Terminal.jsx +++ b/src/Terminal.jsx @@ -91,7 +91,6 @@ export const NewTerminal = forwardRef(({ hostConfig, isVisible }, ref) => { }); terminalInstance.current.attachCustomKeyEventHandler((event) => { - console.log("Event caled"); if (isPasting) return; isPasting = true; diff --git a/src/User.jsx b/src/User.jsx new file mode 100644 index 00000000..5863bcfc --- /dev/null +++ b/src/User.jsx @@ -0,0 +1,129 @@ +import { useRef, forwardRef, useImperativeHandle } from "react"; +import io from "socket.io-client"; +import PropTypes from "prop-types"; + +let socket; + +if (!socket) { + socket = io( + window.location.hostname === "localhost" + ? "http://localhost:8082" + : "/", + { + path: "/socket.io", + transports: ["websocket", "polling"], + } + ); +} + +export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSuccess, onFailure }, ref) => { + const socketRef = useRef(socket); + const currentUser = useRef(null); + + const createUser = (userConfig) => { + if (socketRef.current) { + socketRef.current.emit("createUser", { + username: userConfig.username, + password: userConfig.password, + }); + + socketRef.current.once("userCreated", (data) => { + console.log("User created", data); + currentUser.current = { + id: data.user._id, + username: data.user.username, + sessionToken: data.user.sessionToken, + }; + localStorage.setItem('sessionToken', data.user.sessionToken); + onCreateSuccess(data); + }); + + socketRef.current.once("error", (error) => { + console.error(error); + const errorMsg = (error && typeof error === 'object' && error !== null) + ? error.error || error.message || 'An error occurred' + : String(error); + onFailure(errorMsg); + }); + } + }; + + const loginUser = (userConfig) => { + if (socketRef.current) { + setTimeout(() => { + socketRef.current.emit("loginUser", { + username: userConfig.username, + password: userConfig.password, + sessionToken: userConfig.sessionToken, + }); + + socketRef.current.once("userFound", (data) => { + console.log("User found", data); + currentUser.current = { + id: data._id, + username: data.username, + sessionToken: data.sessionToken, + }; + localStorage.setItem('sessionToken', data.sessionToken); + onLoginSuccess(data); + }); + + socketRef.current.once("error", (error) => { + console.error(error); + const errorMsg = (error && typeof error === 'object' && error !== null) + ? error.error || error.message || 'An error occurred' + : String(error); + onFailure(errorMsg); + }); + }, 500); + } + }; + + const logoutUser = () => { + localStorage.removeItem('sessionToken'); + currentUser.current = null; + }; + + const deleteUser = () => { + if (currentUser.current?.id && socketRef.current) { + socketRef.current.emit("deleteUser", { + userId: currentUser.current.id, + }); + + socketRef.current.once("userDeleted", (data) => { + console.log("User deleted", data); + onDeleteSuccess(data); + currentUser.current = null; + localStorage.removeItem('sessionToken'); + }); + + socketRef.current.once("error", (error) => { + console.error(error); + const errorMsg = (error && typeof error === 'object' && error !== null) + ? error.error || error.message || 'An error occurred' + : String(error); + onFailure(errorMsg); + }); + } else { + onFailure("No user is currently logged in."); + } + }; + + useImperativeHandle(ref, () => ({ + createUser, + loginUser, + logoutUser, + deleteUser, + })); + + return
; +}); + +User.displayName = "User"; + +User.propTypes = { + onLoginSuccess: PropTypes.func.isRequired, + onCreateSuccess: PropTypes.func.isRequired, + onDeleteSuccess: PropTypes.func.isRequired, + onFailure: PropTypes.func.isRequired, +}; \ No newline at end of file diff --git a/src/backend/database.cjs b/src/backend/database.cjs new file mode 100644 index 00000000..e72dec6e --- /dev/null +++ b/src/backend/database.cjs @@ -0,0 +1,156 @@ +const http = require("http"); +const socketIo = require("socket.io"); +const mongoose = require("mongoose"); +const crypto = require('crypto'); + +const server = http.createServer(); +const io = socketIo(server, { + cors: { + origin: "*", + methods: ["GET", "POST"], + credentials: true + }, + allowEIO3: true +}); + +async function connectToMongoDB() { + try { + const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost:27017/termix'; + await mongoose.connect(mongoUrl, {}); + console.log('Connected to MongoDB'); + + const db = mongoose.connection.db; + + // Create the 'users' collection if it doesn't exist + const collections = await db.listCollections().toArray(); + if (!collections.find(col => col.name === 'users')) { + await db.createCollection('users'); + console.log('Successfully created collection: users'); + } + } catch (error) { + console.error('Error connecting to MongoDB:', error); + } +} + +const userSchema = new mongoose.Schema({ + username: { type: String, required: true, unique: true }, + password: { type: String, required: true }, + sessionToken: { type: String, required: true }, + sshConnections: { type: [Object], default: [] }, +}); + +const User = mongoose.model('User', userSchema); + +async function createUser(username, password) { + try { + const userExists = await User.findOne({ username }); + if (userExists) { + return { error: "User already exists for username" }; + } + + const sessionToken = crypto.randomBytes(64).toString('hex'); + const newUser = new User({ username, password, sessionToken }); + await newUser.save(); + return { success: true, user: { _id: newUser._id, username: newUser.username, sessionToken: newUser.sessionToken } }; + } catch (err) { + return { error: 'Error creating user: ' + err.message }; + } +} + +async function loginUser(username, password) { + try { + const user = await User.findOne({ username, password }); + if (user) { + if (!user.sessionToken) { + user.sessionToken = crypto.randomBytes(64).toString('hex'); + await user.save(); + } + return { + _id: user._id, + username: user.username, + sessionToken: user.sessionToken, + }; + } else { + return { error: 'User not found or incorrect credentials for username' }; + } + } catch (err) { + return { error: 'Error checking user: ' + err.message }; + } +} + +async function loginWithToken(sessionToken) { + try { + const user = await User.findOne({ sessionToken }); + if (user) { + return { + _id: user._id, + username: user.username, + sessionToken: user.sessionToken, + }; + } else { + return { error: 'Invalid session token' }; + } + } catch (err) { + return { error: 'Error checking session token: ' + err.message }; + } +} + +async function deleteUser(userId) { + try { + const user = await User.findById(userId); + if (user) { + await User.deleteOne({ _id: userId }); + return { success: true }; + } else { + return { error: 'User not found'}; + } + } catch (err) { + return { error: 'Error removing user: ' + err.message }; + } +} + +io.on("connection", (socket) => { + console.log("New socket connection established"); + + socket.on("createUser", async (data) => { + const { username, password } = data; + if (!username || !password) { + socket.emit("error", "Please provide both username and password"); + return; + } + const result = await createUser(username, password); + socket.emit(result.error ? "error" : "userCreated", result); + console.log(result.error || `User created`); + }); + + socket.on("loginUser", async (data) => { + const { username, password, sessionToken } = data; + let result; + if (sessionToken) { + result = await loginWithToken(sessionToken); + } else if (username && password) { + result = await loginUser(username, password); + } else { + socket.emit("error", "Please provide both username and password or a session token"); + return; + } + socket.emit(result.error ? "error" : "userFound", result); + console.log(result.error || `User logged in`); + }); + + socket.on("deleteUser", async (data) => { + const { userId } = data; + if (!userId) { + socket.emit("error", "User ID is required"); + return; + } + const result = await deleteUser(userId); + socket.emit(result.error ? "error" : "userDeleted", result); + console.log(result.error || `User deleted`); + }); +}); + +server.listen(8082, '0.0.0.0', async () => { + console.log("Server is running on port 8082"); + await connectToMongoDB(); +}); \ No newline at end of file diff --git a/src/images/profile_icon.png b/src/images/profile_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9822db4b8b8d0678bfd017ed62c161bbabbfc8 GIT binary patch literal 26728 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 O(@@n>sa1ab^8Wxi(DF|J literal 0 HcmV?d00001 diff --git a/vite.config.js b/vite.config.js index 5399993d..4f806a60 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,4 +5,10 @@ import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + + server: { + watch: { + ignored: ["**/docker/**"], + }, + }, }) \ No newline at end of file