├── .env.exemple ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── create_release.yml │ ├── deploy_docker_dev.yml │ ├── deploy_docs.yml │ └── merge-dependabot.yml ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── LICENSE ├── NOTICE ├── README.md ├── client ├── .gitignore ├── eslint.config.js ├── index.html ├── jsconfig.json ├── package.json ├── public │ └── assets │ │ └── img │ │ ├── favicon.png │ │ └── favicon.svg ├── src │ ├── App.jsx │ ├── common │ │ ├── components │ │ │ ├── ActionConfirmDialog │ │ │ │ ├── ActionConfirmDialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── Button │ │ │ │ ├── Button.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── Dialog │ │ │ │ ├── Dialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── IconInput │ │ │ │ ├── IconInput.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── LoginDialog │ │ │ │ ├── LoginDialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── SelectBox │ │ │ │ ├── SelectBox.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ └── Sidebar │ │ │ │ ├── Sidebar.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ ├── contexts │ │ │ ├── IdentityContext.jsx │ │ │ ├── ServerContext.jsx │ │ │ ├── SessionContext.jsx │ │ │ ├── SnippetContext.jsx │ │ │ ├── ThemeContext.jsx │ │ │ ├── ToastContext.jsx │ │ │ └── UserContext.jsx │ │ ├── img │ │ │ ├── logo.png │ │ │ └── welcome.png │ │ ├── layouts │ │ │ └── Root.jsx │ │ ├── styles │ │ │ ├── _colors.sass │ │ │ ├── main.sass │ │ │ └── toast.sass │ │ └── utils │ │ │ └── RequestUtil.js │ ├── main.jsx │ └── pages │ │ ├── Apps │ │ ├── Apps.jsx │ │ ├── components │ │ │ ├── AppInstaller │ │ │ │ ├── AppInstaller.jsx │ │ │ │ ├── components │ │ │ │ │ ├── InstallStep │ │ │ │ │ │ ├── InstallStep.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ └── LogDialog │ │ │ │ │ │ ├── LogDialog.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ ├── os_images │ │ │ │ │ ├── debian.png │ │ │ │ │ ├── linux.png │ │ │ │ │ └── ubuntu.png │ │ │ │ └── styles.sass │ │ │ ├── AppItem │ │ │ │ ├── AppItem.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── AppNavigation │ │ │ │ ├── AppNavigation.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── DeployServerDialog │ │ │ │ ├── DeployServerDialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── SourceDialog │ │ │ │ ├── SourceDialog.jsx │ │ │ │ ├── components │ │ │ │ │ └── SourceItem │ │ │ │ │ │ ├── SourceItem.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ └── StoreHeader │ │ │ │ ├── StoreHeader.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ ├── index.js │ │ └── styles.sass │ │ ├── Servers │ │ ├── Servers.jsx │ │ ├── components │ │ │ ├── ProxmoxDialog │ │ │ │ ├── ProxmoxDialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── ServerDialog │ │ │ │ ├── ServerDialog.jsx │ │ │ │ ├── index.js │ │ │ │ ├── pages │ │ │ │ │ ├── DetailsPage.jsx │ │ │ │ │ ├── IdentityPage.jsx │ │ │ │ │ └── SettingsPage.jsx │ │ │ │ └── styles.sass │ │ │ ├── ServerList │ │ │ │ ├── ServerList.jsx │ │ │ │ ├── components │ │ │ │ │ ├── CollapsibleFolder.jsx │ │ │ │ │ ├── ContextMenu │ │ │ │ │ │ ├── ContextMenu.jsx │ │ │ │ │ │ ├── assets │ │ │ │ │ │ │ └── proxmox.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ ├── FolderObject │ │ │ │ │ │ ├── FolderObject.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ ├── OrganizationFolder │ │ │ │ │ │ ├── OrganizationFolder.jsx │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── PVEObject │ │ │ │ │ │ ├── PVEObject.jsx │ │ │ │ │ │ ├── assets │ │ │ │ │ │ │ └── proxmox.png │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ ├── ServerEntries.jsx │ │ │ │ │ ├── ServerObject │ │ │ │ │ │ ├── ServerObject.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ └── ServerSearch │ │ │ │ │ │ ├── ServerSearch.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ └── ViewContainer │ │ │ │ ├── ViewContainer.jsx │ │ │ │ ├── components │ │ │ │ └── ServerTabs │ │ │ │ │ ├── ServerTabs.jsx │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ ├── renderer │ │ │ │ ├── FileRenderer │ │ │ │ │ ├── FileRenderer.jsx │ │ │ │ │ ├── components │ │ │ │ │ │ ├── ActionBar │ │ │ │ │ │ │ ├── ActionBar.jsx │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── styles.sass │ │ │ │ │ │ ├── CreateFolderDialog │ │ │ │ │ │ │ ├── CreateFolderDialog.jsx │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── styles.sass │ │ │ │ │ │ ├── FileEditor │ │ │ │ │ │ │ ├── FileEditor.jsx │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── styles.sass │ │ │ │ │ │ └── FileList │ │ │ │ │ │ │ ├── FileList.jsx │ │ │ │ │ │ │ ├── components │ │ │ │ │ │ │ ├── ContextMenu │ │ │ │ │ │ │ │ ├── ContextMenu.jsx │ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ │ └── RenameItemDialog │ │ │ │ │ │ │ │ ├── RenameItemDialog.jsx │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ └── styles.sass │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── styles.sass │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.sass │ │ │ │ ├── GuacamoleRenderer.jsx │ │ │ │ ├── XtermRenderer.jsx │ │ │ │ ├── components │ │ │ │ │ └── SnippetsMenu │ │ │ │ │ │ ├── SnippetsMenu.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ └── styles │ │ │ │ │ └── xterm.sass │ │ │ │ └── styles.sass │ │ ├── index.js │ │ └── styles.sass │ │ ├── Settings │ │ ├── Settings.jsx │ │ ├── components │ │ │ └── SettingsNavigation │ │ │ │ ├── SettingsNavigation.jsx │ │ │ │ ├── components │ │ │ │ └── SettingsItem │ │ │ │ │ ├── SettingsItem.jsx │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ ├── index.js │ │ ├── pages │ │ │ ├── Account │ │ │ │ ├── Account.jsx │ │ │ │ ├── dialogs │ │ │ │ │ ├── PasswordChange │ │ │ │ │ │ ├── PasswordChange.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ └── TwoFactorAuthentication │ │ │ │ │ │ ├── TwoFactorAuthentication.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── Authentication │ │ │ │ ├── Authentication.jsx │ │ │ │ ├── components │ │ │ │ │ └── ProviderDialog │ │ │ │ │ │ ├── ProviderDialog.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── Organizations │ │ │ │ ├── Organizations.jsx │ │ │ │ ├── components │ │ │ │ │ ├── InviteMemberDialog │ │ │ │ │ │ ├── InviteMemberDialog.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ ├── MemberList │ │ │ │ │ │ ├── MemberList.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ └── OrganizationDialog │ │ │ │ │ │ ├── OrganizationDialog.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── Sessions │ │ │ │ ├── Sessions.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ └── Users │ │ │ │ ├── Users.jsx │ │ │ │ ├── components │ │ │ │ ├── ContextMenu │ │ │ │ │ ├── ContextMenu.jsx │ │ │ │ │ └── index.js │ │ │ │ └── CreateUserDialog │ │ │ │ │ ├── CreateUserDialog.jsx │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ └── styles.sass │ │ └── Snippets │ │ ├── Snippets.jsx │ │ ├── components │ │ ├── SnippetDialog │ │ │ ├── SnippetDialog.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ └── SnippetsList │ │ │ ├── SnippetsList.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ ├── index.js │ │ └── styles.sass ├── vite.config.js └── yarn.lock ├── docker-start.sh ├── docs ├── .vitepress │ └── config.mjs ├── assets │ └── images │ │ └── migration.png ├── contributing.md ├── index.md ├── powertools-migration.md ├── preview.md └── public │ ├── CNAME │ ├── favicon.ico │ ├── logo.png │ └── thumbnail.png ├── package.json ├── server ├── controllers │ ├── account.js │ ├── appSource.js │ ├── auth.js │ ├── folder.js │ ├── guacamoleProxy.js │ ├── identity.js │ ├── oidc.js │ ├── organization.js │ ├── pve.js │ ├── pveServer.js │ ├── server.js │ ├── session.js │ └── snippet.js ├── index.js ├── lib │ ├── ClientConnection.js │ └── GuacdClient.js ├── middlewares │ ├── auth.js │ ├── guacamole.js │ ├── permission.js │ └── pve.js ├── models │ ├── Account.js │ ├── AppSource.js │ ├── Folder.js │ ├── Identity.js │ ├── OIDCProvider.js │ ├── Organization.js │ ├── OrganizationMember.js │ ├── PVEServer.js │ ├── Server.js │ ├── Session.js │ └── Snippet.js ├── routes │ ├── account.js │ ├── appInstaller.js │ ├── apps.js │ ├── auth.js │ ├── folder.js │ ├── identity.js │ ├── oidc.js │ ├── organization.js │ ├── pveLXC.js │ ├── pveQEMU.js │ ├── pveServer.js │ ├── server.js │ ├── service.js │ ├── session.js │ ├── sftp.js │ ├── sftpDownload.js │ ├── snippet.js │ ├── sshd.js │ └── users.js ├── templates │ └── env.html ├── utils │ ├── apps │ │ ├── checkDistro.js │ │ ├── checkPermissions.js │ │ ├── installDocker.js │ │ ├── pullImage.js │ │ ├── runCommand.js │ │ └── startContainer.js │ ├── database.js │ ├── encryption.js │ ├── error.js │ ├── errorHandling.js │ ├── folder.js │ ├── permission.js │ ├── prepareSSH.js │ ├── pveUpdater.js │ ├── schema.js │ ├── sshPreCheck.js │ └── tokenGenerator.js └── validations │ ├── account.js │ ├── appSource.js │ ├── auth.js │ ├── folder.js │ ├── identity.js │ ├── oidc.js │ ├── organization.js │ ├── pveServer.js │ ├── server.js │ ├── snippet.js │ └── users.js └── yarn.lock /.env.exemple: -------------------------------------------------------------------------------- 1 | ENCRYPTION_KEY="aba3aa8e29b9904d5d8d705230b664c053415c54be20ad13be99af0057dfa23a" // Random key generated by crypto.randomBytes(32).toString("hex") 2 | SERVER_PORT=6989 3 | NODE_ENV=development -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: gnmyt 2 | ko_fi: gnmyt -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Report a Bug 2 | description: Report a bug or issue in Nexterm 3 | title: "[Bug] " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: General 9 | description: Please confirm the following statements 10 | options: 11 | - label: I have updated to the latest version of Nexterm. 12 | required: true 13 | - label: My bug has not been reported yet. 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: The Bug 18 | description: Describe the bug/issue in detail. If possible, include screenshots and provide steps to reproduce the error. 19 | placeholder: An error occurs when ... 20 | validations: 21 | required: true 22 | - type: dropdown 23 | id: browsers 24 | attributes: 25 | label: What device are you using to access the page? 26 | multiple: true 27 | options: 28 | - In the browser 29 | - On mobile 30 | - On a tablet 31 | validations: 32 | required: true 33 | - type: dropdown 34 | id: server 35 | attributes: 36 | label: Which operating system is your Nexterm instance running on? 37 | multiple: true 38 | options: 39 | - Linux 40 | - Windows 41 | - macOS 42 | validations: 43 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Suggest idea 2 | description: Do you have an idea? You're in the right place 3 | title: "[Feature] " 4 | labels: ["enhancement"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: General 9 | description: Please confirm the following statements 10 | options: 11 | - label: My feature does not exist in the latest version of Nexterm. 12 | required: true 13 | - label: I have checked that my feature has not been suggested by anyone else. 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: Your Idea 18 | description: What can we add? Describe your idea as precisely as possible. 19 | placeholder: My idea would be to ... 20 | validations: 21 | required: true -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📋 Description 2 | 3 | Please include a summary of the changes and the related issue. Explain the problem that you are solving and provide the 4 | necessary context. 5 | 6 | ## 🚀 Changes made to ... 7 | 8 | - [ ] 🔧 Server 9 | - [ ] 🖥️ Client 10 | - [ ] 📚 Documentation 11 | - [ ] 🔄 Other: ___ 12 | 13 | ## ✅ Checklist 14 | 15 | - [ ] My code follows the style guidelines of this project 16 | - [ ] I have performed a self-review of my own code 17 | - [ ] I have looked for similar pull requests in the repository and found none 18 | 19 | ## 🔗 Related Issues 20 | 21 | Fixes #(issue) 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | - package-ecosystem: 'npm' 8 | directory: '/client' 9 | schedule: 10 | interval: 'daily' -------------------------------------------------------------------------------- /.github/workflows/deploy_docker_dev.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Development Release to DockerHub 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Set up QEMU 15 | uses: docker/setup-qemu-action@master 16 | with: 17 | platforms: all 18 | 19 | - name: Set up Docker Build 20 | uses: docker/setup-buildx-action@v2 21 | 22 | - name: Login to DockerHub 23 | uses: docker/login-action@v2 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Build and push 29 | uses: docker/build-push-action@v3 30 | with: 31 | push: true 32 | platforms: linux/amd64 33 | tags: germannewsmaker/nexterm:development -------------------------------------------------------------------------------- /.github/workflows/deploy_docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress site to Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ['docs/**'] 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: pages 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - name: Setup Node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20 30 | cache: yarn 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v4 33 | - name: Install dependencies 34 | run: yarn install 35 | - name: Build with VitePress 36 | run: yarn run docs:build 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: docs/.vitepress/dist 41 | 42 | deploy: 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | needs: build 47 | runs-on: ubuntu-latest 48 | name: Deploy 49 | steps: 50 | - name: Deploy to GitHub Pages 51 | id: deployment 52 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.github/workflows/merge-dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-approve 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'gnmyt/Nexterm' 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Approve a PR 19 | run: gh pr merge --auto --merge "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": false 6 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS client-builder 2 | 3 | WORKDIR /app/client 4 | 5 | COPY client/package.json ./ 6 | RUN npm install 7 | 8 | COPY client/ . 9 | RUN npm run build 10 | 11 | FROM node:22-alpine 12 | 13 | # This is required as the newest version (1.6.0) breaks compatibility with the Proxmox integration. 14 | # Related issue: https://issues.apache.org/jira/browse/GUACAMOLE-1877 15 | ARG GUACD_COMMIT=daffc29a958e8d07af32def00d2d98d930df317a 16 | 17 | RUN apk add --no-cache \ 18 | cairo-dev jpeg-dev libpng-dev ossp-uuid-dev ffmpeg-dev \ 19 | pango-dev libvncserver-dev libwebp-dev openssl-dev freerdp-dev \ 20 | autoconf automake libtool libpulse libogg libc-dev \ 21 | python3 py3-pip py3-setuptools make gcc g++ \ 22 | && python3 -m venv /opt/venv \ 23 | && . /opt/venv/bin/activate \ 24 | && pip install --upgrade pip setuptools \ 25 | && deactivate \ 26 | && apk add --no-cache --virtual .build-deps build-base git 27 | 28 | RUN git clone https://github.com/apache/guacamole-server.git \ 29 | && cd guacamole-server \ 30 | && git checkout $GUACD_COMMIT \ 31 | && autoreconf -fi \ 32 | && ./configure --with-init-dir=/etc/init.d \ 33 | && make -j$(nproc) \ 34 | && make install \ 35 | && cd .. \ 36 | && rm -rf guacamole-server 37 | 38 | RUN apk del .build-deps \ 39 | && rm -rf /var/cache/apk/* 40 | 41 | ENV NODE_ENV=production 42 | 43 | WORKDIR /app 44 | 45 | COPY --from=client-builder /app/client/dist ./dist 46 | 47 | COPY package.json ./ 48 | RUN npm install --omit=dev && npm cache clean --force 49 | 50 | COPY server/ server/ 51 | COPY docker-start.sh . 52 | 53 | RUN chmod +x docker-start.sh 54 | 55 | EXPOSE 6989 56 | 57 | CMD ["/bin/sh", "docker-start.sh"] 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mathias Wagner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | This project contains code licensed under the Apache License 2.0. 2 | 3 | The following files are subject to the Apache License 2.0: 4 | 5 | - /server/lib/ClientConnection.js 6 | - /server/lib/GuacdClient.js 7 | 8 | Copyright 2024 Vadim Pronin 9 | 10 | Licensed under the Apache License, Version 2.0 (the "License"); 11 | you may not use these files except in compliance with the License. 12 | You may obtain a copy of the License at: 13 | 14 | http://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import react from 'eslint-plugin-react' 4 | import reactHooks from 'eslint-plugin-react-hooks' 5 | import reactRefresh from 'eslint-plugin-react-refresh' 6 | 7 | export default [ 8 | { ignores: ['dist'] }, 9 | { 10 | files: ['**/*.{js,jsx}'], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | parserOptions: { 15 | ecmaVersion: 'latest', 16 | ecmaFeatures: { jsx: true }, 17 | sourceType: 'module', 18 | }, 19 | }, 20 | settings: { react: { version: '18.3' } }, 21 | plugins: { 22 | react, 23 | 'react-hooks': reactHooks, 24 | 'react-refresh': reactRefresh, 25 | }, 26 | rules: { 27 | ...js.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs['jsx-runtime'].rules, 30 | ...reactHooks.configs.recommended.rules, 31 | 'react/jsx-no-target-blank': 'off', 32 | 'react-refresh/only-export-components': [ 33 | 'warn', 34 | { allowConstantExport: true }, 35 | ], 36 | }, 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Nexterm 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "lint": "eslint .", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@fontsource/plus-jakarta-sans": "^5.2.5", 13 | "@mdi/js": "^7.4.47", 14 | "@mdi/react": "^1.6.1", 15 | "@uiw/codemirror-theme-github": "^4.23.12", 16 | "@uiw/react-codemirror": "^4.23.12", 17 | "guacamole-common-js": "^1.5.0", 18 | "qrcode.react": "^4.2.0", 19 | "react": "^19.1.0", 20 | "react-dnd": "^16.0.1", 21 | "react-dnd-html5-backend": "^16.0.1", 22 | "react-dom": "^19.1.0", 23 | "react-router-dom": "^7.6.1", 24 | "react-use-websocket": "^4.13.0", 25 | "simple-icons": "^15.0.0", 26 | "ua-parser-js": "^2.0.3", 27 | "xterm": "^5.3.0", 28 | "xterm-addon-fit": "^0.8.0" 29 | }, 30 | "devDependencies": { 31 | "@eslint/js": "^9.28.0", 32 | "@types/react": "^19.1.6", 33 | "@types/react-dom": "^19.1.5", 34 | "@vitejs/plugin-react": "^4.5.0", 35 | "eslint": "^9.28.0", 36 | "eslint-plugin-react": "^7.37.5", 37 | "eslint-plugin-react-hooks": "^5.2.0", 38 | "eslint-plugin-react-refresh": "^0.4.20", 39 | "globals": "^16.2.0", 40 | "sass-embedded": "^1.89.1", 41 | "vite": "^6.3.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/public/assets/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/client/public/assets/img/favicon.png -------------------------------------------------------------------------------- /client/public/assets/img/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import "@fontsource/plus-jakarta-sans/300.css"; 2 | import "@fontsource/plus-jakarta-sans/400.css"; 3 | import "@fontsource/plus-jakarta-sans/600.css"; 4 | import "@fontsource/plus-jakarta-sans/700.css"; 5 | import "@fontsource/plus-jakarta-sans/800.css"; 6 | import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom"; 7 | import Root from "@/common/layouts/Root.jsx"; 8 | import Servers from "@/pages/Servers"; 9 | import "@/common/styles/main.sass"; 10 | import Settings from "@/pages/Settings"; 11 | import Apps from "@/pages/Apps"; 12 | import Snippets from "@/pages/Snippets"; 13 | 14 | export const GITHUB_URL = "https://github.com/gnmyt/Nexterm"; 15 | export const DISCORD_URL = "https://dc.gnmyt.dev/"; 16 | 17 | const App = () => { 18 | const router = createBrowserRouter([ 19 | { 20 | path: "/", 21 | element: , 22 | children: [ 23 | { path: "/", element: }, 24 | { path: "/servers", element: }, 25 | { path: "/settings/*", element: }, 26 | { path: "/apps/*", element: }, 27 | { path: "/snippets", element: } 28 | ], 29 | }, 30 | ]); 31 | 32 | return ; 33 | } 34 | 35 | export default App; -------------------------------------------------------------------------------- /client/src/common/components/ActionConfirmDialog/ActionConfirmDialog.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import { DialogProvider } from "@/common/components/Dialog"; 3 | import Button from "@/common/components/Button"; 4 | 5 | export const ActionConfirmDialog = ({open, setOpen, onConfirm, onCancel, text}) => { 6 | 7 | const cancel = () => { 8 | setOpen(false); 9 | 10 | if (onCancel) { 11 | onCancel(); 12 | } 13 | } 14 | 15 | const confirm = () => { 16 | setOpen(false); 17 | 18 | if (onConfirm) { 19 | onConfirm(); 20 | } 21 | } 22 | 23 | return ( 24 | setOpen(false)} open={open}> 25 |
26 |

Are you sure?

27 |

{text ? text : "This action cannot be undone."}

28 |
29 |
32 |
33 |
34 | ) 35 | } -------------------------------------------------------------------------------- /client/src/common/components/ActionConfirmDialog/index.js: -------------------------------------------------------------------------------- 1 | export {ActionConfirmDialog as default} from "./ActionConfirmDialog"; -------------------------------------------------------------------------------- /client/src/common/components/ActionConfirmDialog/styles.sass: -------------------------------------------------------------------------------- 1 | .confirm-dialog 2 | display: flex 3 | flex-direction: column 4 | gap: 1rem 5 | width: 20rem 6 | 7 | h2 8 | margin: 0 9 | 10 | p 11 | margin: 0 12 | font-size: 1.1rem 13 | font-weight: 600 14 | 15 | 16 | .btn-area 17 | display: flex 18 | gap: 1rem 19 | justify-content: flex-end -------------------------------------------------------------------------------- /client/src/common/components/Button/Button.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Icon from "@mdi/react"; 3 | 4 | export const Button = ({onClick, text, icon, disabled, type}) => { 5 | return ( 6 | 10 | ); 11 | } -------------------------------------------------------------------------------- /client/src/common/components/Button/index.js: -------------------------------------------------------------------------------- 1 | export {Button as default} from "./Button.jsx"; -------------------------------------------------------------------------------- /client/src/common/components/Button/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .btn 4 | display: flex 5 | align-items: center 6 | justify-content: center 7 | gap: 0.5rem 8 | background-color: colors.$primary-opacity 9 | border: 1px solid colors.$gray 10 | color: colors.$white 11 | border-radius: 0.5rem 12 | padding: 0.8rem 1rem 13 | cursor: pointer 14 | transition: all 0.2s 15 | 16 | svg 17 | width: 1.1rem 18 | height: 1.1rem 19 | 20 | h3 21 | margin: 0 22 | font-size: 1rem 23 | font-weight: 500 24 | color: colors.$white 25 | 26 | &:hover 27 | filter: brightness(0.8) 28 | 29 | &:active 30 | transform: scale(0.97) 31 | 32 | &:disabled 33 | background-color: colors.$gray 34 | cursor: not-allowed 35 | 36 | .type-secondary 37 | background-color: colors.$gray 38 | 39 | .type-danger 40 | background-color: colors.$error-opacity -------------------------------------------------------------------------------- /client/src/common/components/Dialog/Dialog.jsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useEffect, useRef, useState } from "react"; 2 | import "./styles.sass"; 3 | 4 | export const DialogContext = createContext({}); 5 | 6 | export const DialogProvider = ({ disableClosing, open, children, onClose }) => { 7 | const areaRef = useRef(); 8 | const ref = useRef(); 9 | 10 | const [isVisible, setIsVisible] = useState(false); 11 | const [isClosing, setIsClosing] = useState(false); 12 | 13 | const closeInner = () => { 14 | setIsClosing(true); 15 | }; 16 | 17 | useEffect(() => { 18 | const handleClick = (event) => { 19 | const isInsideDialog = ref.current?.contains(event.target); 20 | const isInsidePortal = !!document.getElementById('select-box-portal')?.contains(event.target); 21 | 22 | if (!isInsideDialog && !isInsidePortal) { 23 | if (!disableClosing) closeInner(); 24 | } 25 | }; 26 | 27 | document.addEventListener("mousedown", handleClick); 28 | return () => document.removeEventListener("mousedown", handleClick); 29 | }, [ref]); 30 | 31 | useEffect(() => { 32 | if (open) { 33 | setIsVisible(true); 34 | setIsClosing(false); 35 | } else if (!isClosing) { 36 | closeInner(); 37 | } 38 | }, [open]); 39 | 40 | const handleAnimationEnd = () => { 41 | if (isClosing) { 42 | setIsVisible(false); 43 | setIsClosing(false); 44 | if (onClose) onClose(); 45 | } 46 | }; 47 | 48 | return ( 49 | 50 | {isVisible && ( 51 |
52 |
54 | {children} 55 |
56 |
57 | )} 58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /client/src/common/components/Dialog/index.js: -------------------------------------------------------------------------------- 1 | export * from "./Dialog.jsx"; -------------------------------------------------------------------------------- /client/src/common/components/Dialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .dialog-area 4 | position: fixed 5 | top: 0 6 | bottom: 0 7 | left: 0 8 | right: 0 9 | width: 100% 10 | height: 100% 11 | background-color: colors.$dialog-background 12 | display: flex 13 | align-items: center 14 | z-index: 1000 15 | justify-content: center 16 | backdrop-filter: blur(0.5rem) 17 | transition: all 0.2s 18 | animation: opacity 0.3s 19 | 20 | .dialog-area-hidden 21 | opacity: 0 22 | animation: opacity 0.3s reverse 23 | 24 | .dialog 25 | padding: 15px 26 | background-color: colors.$darker-gray 27 | border: 1px solid colors.$dark-gray 28 | color: colors.$white 29 | backdrop-filter: blur(1rem) 30 | border-radius: 15px 31 | max-width: 90% 32 | max-height: 90vh 33 | overflow-y: auto 34 | transition: all 0.2s 35 | animation: fadeIn 0.3s 36 | 37 | .dialog-hidden 38 | visibility: hidden 39 | opacity: 0 40 | animation: fadeOut 0.3s 41 | 42 | @keyframes fadeIn 43 | 0% 44 | opacity: 0 45 | transform: scale(0.4) 46 | filter: blur(5px) 47 | 100% 48 | opacity: 1 49 | 50 | @keyframes fadeOut 51 | 0% 52 | opacity: 1 53 | 100% 54 | opacity: 0 55 | transform: scale(0.4) 56 | filter: blur(5px) -------------------------------------------------------------------------------- /client/src/common/components/IconInput/IconInput.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Icon from "@mdi/react"; 3 | 4 | export const IconInput = ({ type, id, name, required, icon, placeholder, customClass, 5 | autoComplete, value, setValue, onChange, onBlur }) => { 6 | return ( 7 |
8 | 9 | setValue ? setValue(event.target.value) : null} /> 12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /client/src/common/components/IconInput/index.js: -------------------------------------------------------------------------------- 1 | export {IconInput as default} from "./IconInput.jsx" -------------------------------------------------------------------------------- /client/src/common/components/IconInput/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .input-container 4 | position: relative 5 | width: 100% 6 | 7 | .input-icon 8 | position: absolute 9 | top: 50% 10 | left: 0.75rem 11 | width: 2rem 12 | height: 2rem 13 | transform: translateY(-50%) 14 | color: colors.$light-gray 15 | 16 | .input 17 | padding: 0.8rem 2rem 0.8rem 3.5rem 18 | border: 1px solid colors.$gray 19 | background-color: colors.$dark-gray 20 | color: colors.$light-gray 21 | box-sizing: border-box 22 | border-radius: 0.7rem 23 | font-size: 14pt 24 | width: 100% 25 | outline: none 26 | 27 | &:focus 28 | border: 1px solid colors.$primary 29 | background-color: colors.$gray -------------------------------------------------------------------------------- /client/src/common/components/LoginDialog/index.js: -------------------------------------------------------------------------------- 1 | export {LoginDialog as default} from "./LoginDialog.jsx"; -------------------------------------------------------------------------------- /client/src/common/components/LoginDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .login-dialog 4 | display: flex 5 | flex-direction: column 6 | align-items: center 7 | justify-content: center 8 | gap: 1rem 9 | user-select: none 10 | margin: 1rem 1.5rem 11 | 12 | .login-logo 13 | display: flex 14 | align-items: center 15 | gap: 1rem 16 | 17 | img 18 | width: 3rem 19 | height: 3rem 20 | 21 | h1 22 | margin: 0 23 | 24 | .login-form 25 | display: flex 26 | flex-direction: column 27 | gap: 1rem 28 | 29 | .register-name-row 30 | display: flex 31 | width: 24rem 32 | gap: 1rem 33 | 34 | input 35 | width: 100% 36 | 37 | .form-group 38 | display: flex 39 | flex-direction: column 40 | gap: 0.3rem 41 | 42 | label 43 | color: colors.$subtext 44 | font-weight: 600 45 | 46 | .sso-options 47 | display: flex 48 | flex-direction: column 49 | gap: 1rem 50 | width: 100% 51 | 52 | .divider 53 | display: flex 54 | align-items: center 55 | text-align: center 56 | color: colors.$subtext 57 | font-size: 0.9rem 58 | margin: 1rem 0 59 | 60 | &::before, 61 | &::after 62 | content: "" 63 | flex: 1 64 | border-bottom: 1px solid colors.$subtext 65 | margin: 0 10px 66 | 67 | .sso-buttons 68 | display: flex 69 | flex-direction: column 70 | gap: 0.5rem 71 | width: 100% -------------------------------------------------------------------------------- /client/src/common/components/SelectBox/index.js: -------------------------------------------------------------------------------- 1 | export {SelectBox as default} from "./SelectBox"; -------------------------------------------------------------------------------- /client/src/common/components/SelectBox/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .select-box 4 | position: relative 5 | user-select: none 6 | cursor: pointer 7 | 8 | &__selected 9 | padding: 0.8rem 10 | border: 1px solid colors.$gray 11 | background-color: colors.$dark-gray 12 | color: colors.$light-gray 13 | box-sizing: border-box 14 | border-radius: 0.7rem 15 | font-size: 14pt 16 | width: 100% 17 | outline: none 18 | display: flex 19 | justify-content: space-between 20 | align-items: center 21 | 22 | svg 23 | width: 1.5rem 24 | height: 1.5rem 25 | 26 | &__arrow 27 | margin-left: 10px 28 | transition: transform 0.3s ease 29 | 30 | &.open 31 | transform: rotate(180deg) 32 | 33 | &__options 34 | position: fixed 35 | border: 1px solid colors.$gray 36 | background-color: colors.$gray-full 37 | backdrop-filter: blur(1rem) 38 | border-radius: 0.7rem 39 | z-index: 9999 40 | 41 | &__options-scroll 42 | max-height: 250px 43 | overflow-y: auto 44 | overflow-x: hidden 45 | scrollbar-width: thin 46 | scrollbar-color: colors.$gray colors.$dark-gray 47 | 48 | &::-webkit-scrollbar 49 | width: 8px 50 | 51 | &::-webkit-scrollbar-track 52 | background: colors.$dark-gray 53 | border-radius: 0 0.7rem 0.7rem 0 54 | 55 | &::-webkit-scrollbar-thumb 56 | background-color: colors.$gray 57 | border-radius: 10px 58 | 59 | &__option 60 | padding: 10px 61 | margin: 5px 62 | border-radius: 0.7rem 63 | display: flex 64 | justify-content: center 65 | cursor: pointer 66 | 67 | &:last-child 68 | border-bottom: none 69 | 70 | &:hover, &.selected 71 | background-color: colors.$gray 72 | -------------------------------------------------------------------------------- /client/src/common/components/Sidebar/index.js: -------------------------------------------------------------------------------- 1 | export {Sidebar as default} from "./Sidebar.jsx"; -------------------------------------------------------------------------------- /client/src/common/components/Sidebar/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .sidebar 4 | width: 5rem 5 | user-select: none 6 | background-color: colors.$lighter-background 7 | height: 100% 8 | display: flex 9 | flex-direction: column 10 | justify-content: space-between 11 | align-items: center 12 | border-right: 2px solid colors.$dark-gray 13 | overflow: hidden 14 | transition: all 0.2s 15 | left: 0 16 | top: 0 17 | z-index: 10 18 | 19 | &.collapsed 20 | margin-left: -5rem 21 | position: absolute 22 | 23 | &:hover 24 | margin-left: 0 25 | box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2) 26 | 27 | .sidebar-top 28 | display: flex 29 | flex-direction: column 30 | align-items: center 31 | 32 | img 33 | margin-top: 0.5rem 34 | width: 3.5rem 35 | height: 3.5rem 36 | cursor: pointer 37 | 38 | hr 39 | background-color: colors.$dark-gray 40 | width: 50% 41 | margin: 1rem 0 42 | border: none 43 | height: 2px 44 | border-radius: 1rem 45 | 46 | nav 47 | display: flex 48 | flex-direction: column 49 | gap: 1rem 50 | 51 | .nav-item 52 | width: 3.75rem 53 | height: 3.75rem 54 | display: flex 55 | justify-content: center 56 | align-items: center 57 | color: colors.$white 58 | border: 1px solid transparent 59 | border-radius: 1rem 60 | transition: all 0.2s 61 | cursor: pointer 62 | 63 | svg 64 | width: 2.5rem 65 | height: 2.5rem 66 | 67 | &:hover:not(.nav-item-disabled) 68 | color: colors.$primary 69 | 70 | &.nav-item-active 71 | background-color: colors.$dark-gray 72 | border: 1px solid colors.$gray 73 | color: colors.$primary 74 | 75 | &.nav-item-disabled 76 | opacity: 0.5 77 | cursor: not-allowed 78 | 79 | .log-out-btn 80 | border-top: 2px solid colors.$dark-gray 81 | width: 100% 82 | height: 3rem 83 | display: flex 84 | justify-content: center 85 | align-items: center 86 | padding: 0.5rem 87 | cursor: pointer 88 | 89 | svg 90 | width: 2rem 91 | height: 2rem 92 | 93 | &:hover 94 | color: colors.$error -------------------------------------------------------------------------------- /client/src/common/contexts/IdentityContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | import { UserContext } from "@/common/contexts/UserContext.jsx"; 3 | import { getRequest } from "@/common/utils/RequestUtil.js"; 4 | 5 | export const IdentityContext = createContext({}); 6 | 7 | export const IdentityProvider = ({ children }) => { 8 | 9 | const [identities, setIdentities] = useState(null); 10 | const {user, sessionToken} = useContext(UserContext); 11 | 12 | const loadIdentities = async () => { 13 | try { 14 | getRequest("/identities/list").then((response) => { 15 | setIdentities(response); 16 | }); 17 | } catch (error) { 18 | console.error("Failed to load identities", error.message); 19 | } 20 | } 21 | 22 | useEffect(() => { 23 | if (user) { 24 | loadIdentities(); 25 | 26 | const interval = setInterval(() => { 27 | loadIdentities(); 28 | }, 5000); 29 | 30 | return () => clearInterval(interval); 31 | } else if (!sessionToken) { 32 | setIdentities([]); 33 | } 34 | }, [user]); 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ) 41 | } -------------------------------------------------------------------------------- /client/src/common/contexts/SessionContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from "react"; 2 | 3 | export const SessionContext = createContext({}); 4 | 5 | export const useActiveSessions = () => useContext(SessionContext); 6 | 7 | export const SessionProvider = ({ children }) => { 8 | const [activeSessions, setActiveSessions] = useState([]); 9 | const [activeSessionId, setActiveSessionId] = useState(null); 10 | 11 | return ( 12 | 14 | {children} 15 | 16 | ); 17 | }; -------------------------------------------------------------------------------- /client/src/common/contexts/SnippetContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | import { UserContext } from "@/common/contexts/UserContext.jsx"; 3 | import { getRequest } from "@/common/utils/RequestUtil.js"; 4 | 5 | export const SnippetContext = createContext({}); 6 | 7 | export const SnippetProvider = ({ children }) => { 8 | const [snippets, setSnippets] = useState([]); 9 | const { user, sessionToken } = useContext(UserContext); 10 | 11 | const loadSnippets = async () => { 12 | try { 13 | const response = await getRequest("/snippets/list"); 14 | setSnippets(response); 15 | } catch (error) { 16 | console.error("Failed to load snippets", error.message); 17 | } 18 | }; 19 | 20 | useEffect(() => { 21 | if (user) { 22 | loadSnippets(); 23 | } else if (!sessionToken) { 24 | setSnippets([]); 25 | } 26 | }, [user]); 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | }; 34 | 35 | export const useSnippets = () => useContext(SnippetContext); -------------------------------------------------------------------------------- /client/src/common/contexts/ThemeContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useEffect } from "react"; 2 | 3 | const ThemeContext = createContext({}); 4 | 5 | export const useTheme = () => useContext(ThemeContext); 6 | 7 | export const ThemeProvider = ({ children }) => { 8 | const [theme, setTheme] = useState(() => { 9 | const savedTheme = localStorage.getItem("theme"); 10 | return savedTheme || "dark"; 11 | }); 12 | 13 | useEffect(() => { 14 | localStorage.setItem("theme", theme); 15 | document.documentElement.setAttribute("data-theme", theme); 16 | }, [theme]); 17 | 18 | const toggleTheme = () => { 19 | setTheme(prevTheme => prevTheme === "dark" ? "light" : "dark"); 20 | }; 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | }; -------------------------------------------------------------------------------- /client/src/common/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/client/src/common/img/logo.png -------------------------------------------------------------------------------- /client/src/common/img/welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/client/src/common/img/welcome.png -------------------------------------------------------------------------------- /client/src/common/layouts/Root.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | import Sidebar from "@/common/components/Sidebar"; 3 | import { UserProvider } from "@/common/contexts/UserContext.jsx"; 4 | import { ServerProvider } from "@/common/contexts/ServerContext.jsx"; 5 | import { IdentityProvider } from "@/common/contexts/IdentityContext.jsx"; 6 | import { ToastProvider } from "@/common/contexts/ToastContext.jsx"; 7 | import { ThemeProvider } from "@/common/contexts/ThemeContext.jsx"; 8 | import { DndProvider } from "react-dnd"; 9 | import { HTML5Backend } from "react-dnd-html5-backend"; 10 | import { SessionProvider } from "@/common/contexts/SessionContext.jsx"; 11 | import { SnippetProvider } from "@/common/contexts/SnippetContext.jsx"; 12 | 13 | export default () => { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 |
26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | ); 38 | } -------------------------------------------------------------------------------- /client/src/common/styles/_colors.sass: -------------------------------------------------------------------------------- 1 | :root[data-theme="dark"] 2 | --background: #000A12 3 | --terminal: #13181C 4 | --lighter-background: #0D161E 5 | --dialog-background: rgba(0, 0, 0, 0.25) 6 | --gray-full: #202429 7 | --darker-gray: rgba(34, 36, 42, 0.6) 8 | --dark-gray: rgba(255, 255, 255, 0.05) 9 | --gray: rgba(255, 255, 255, 0.1) 10 | --light-gray: #F6F6F6 11 | --primary: #314BD3 12 | --primary-opacity: rgba(49, 75, 211, 0.25) 13 | --error: #a44747 14 | --error-opacity: rgba(164, 71, 71, 0.25) 15 | --success: #29C16A 16 | --success-opacity: rgba(41, 193, 106, 0.25) 17 | --warning: #DC5600 18 | --warning-opacity: rgba(220, 86, 0, 0.25) 19 | --white: #FFFFFF 20 | --subtext: #B7B7B7 21 | --text: #FFFFFF 22 | 23 | :root[data-theme="light"] 24 | --background: #FFFFFF 25 | --terminal: #F5F5F5 26 | --lighter-background: #F0F0F0 27 | --dialog-background: rgba(245, 245, 245, 0.31) 28 | --gray-full: #E5E5E5 29 | --darker-gray: rgba(241, 239, 239, 0.92) 30 | --dark-gray: rgba(0, 0, 0, 0.05) 31 | --gray: rgba(0, 0, 0, 0.1) 32 | --light-gray: #333333 33 | --primary: #314BD3 34 | --primary-opacity: rgba(49, 75, 211, 0.15) 35 | --error: #d85959 36 | --error-opacity: rgba(216, 89, 89, 0.15) 37 | --success: #29C16A 38 | --success-opacity: rgba(41, 193, 106, 0.15) 39 | --warning: #DC5600 40 | --warning-opacity: rgba(220, 86, 0, 0.15) 41 | --white: #000000 42 | --subtext: #666666 43 | --text: #000000 44 | 45 | $background: var(--background) 46 | $dialog-background: var(--dialog-background) 47 | $terminal: var(--terminal) 48 | $lighter-background: var(--lighter-background) 49 | $gray-full: var(--gray-full) 50 | $darker-gray: var(--darker-gray) 51 | $dark-gray: var(--dark-gray) 52 | $gray: var(--gray) 53 | $light-gray: var(--light-gray) 54 | $primary: var(--primary) 55 | $primary-opacity: var(--primary-opacity) 56 | $error: var(--error) 57 | $error-opacity: var(--error-opacity) 58 | $success: var(--success) 59 | $success-opacity: var(--success-opacity) 60 | $warning: var(--warning) 61 | $warning-opacity: var(--warning-opacity) 62 | $white: var(--white) 63 | $subtext: var(--subtext) -------------------------------------------------------------------------------- /client/src/common/styles/main.sass: -------------------------------------------------------------------------------- 1 | @use "colors" 2 | 3 | body, html 4 | margin: 0 5 | overflow-x: hidden 6 | background-color: colors.$background 7 | color: colors.$white 8 | font-family: "Plus Jakarta Sans", sans-serif 9 | font-weight: 700 10 | 11 | .content-wrapper 12 | display: flex 13 | height: 100vh 14 | width: 100vw 15 | 16 | > * 17 | min-width: 0 18 | flex-shrink: 0 19 | 20 | .sidebar 21 | width: calc(5rem + 2px) 22 | flex-shrink: 0 23 | 24 | .main-content 25 | flex: 1 26 | height: 100% 27 | overflow-y: auto 28 | > * 29 | height: 100% 30 | 31 | ::-webkit-scrollbar 32 | width: 13px 33 | 34 | ::-webkit-scrollbar-thumb 35 | background: colors.$lighter-background 36 | border-radius: 10px 37 | 38 | ::-webkit-scrollbar-thumb:hover 39 | filter: brightness(1.2) -------------------------------------------------------------------------------- /client/src/common/utils/RequestUtil.js: -------------------------------------------------------------------------------- 1 | export const request = async (url, method, body, headers) => { 2 | url = url.startsWith("/") ? url.substring(1) : url; 3 | 4 | const response = await fetch(`/api/${url}`, { 5 | method: method, 6 | headers: {...headers, "Content-Type": "application/json"}, 7 | body: JSON.stringify(body) 8 | }); 9 | 10 | if (response.status === 401) throw new Error("Unauthorized"); 11 | 12 | const rawData = await response.text(); 13 | const data = rawData ? JSON.parse(rawData) : rawData.toString(); 14 | 15 | if (data.code >= 300) throw data; 16 | 17 | if (!response.ok) throw data; 18 | 19 | return data; 20 | } 21 | 22 | export const downloadRequest = async (url) => { 23 | const response = await fetch(url, { 24 | method: "GET", 25 | headers: {"Content-Type": "application/json"}, 26 | }); 27 | 28 | if (response.status === 401) throw new Error("Unauthorized"); 29 | 30 | const blob = await response.blob(); 31 | 32 | if (!response.ok) throw blob; 33 | 34 | return blob; 35 | } 36 | 37 | const getToken = () => { 38 | return localStorage.getItem("overrideToken") || localStorage.getItem("sessionToken"); 39 | } 40 | 41 | export const sessionRequest = (url, method, token, body) => { 42 | return request(url, method, body, {"Authorization": `Bearer ${token}`}); 43 | } 44 | 45 | export const getRequest = (url) => { 46 | return sessionRequest(url, "GET", getToken()); 47 | } 48 | 49 | export const postRequest = (url, body) => { 50 | return sessionRequest(url, "POST", getToken(), body); 51 | } 52 | 53 | export const putRequest = (url, body) => { 54 | return sessionRequest(url, "PUT", getToken(), body); 55 | } 56 | 57 | export const deleteRequest = (url) => { 58 | return sessionRequest(url, "DELETE", getToken()); 59 | } 60 | 61 | export const patchRequest = (url, body) => { 62 | return sessionRequest(url, "PATCH", getToken(), body); 63 | } -------------------------------------------------------------------------------- /client/src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "@/App.jsx"; 4 | 5 | createRoot(document.getElementById("root")).render( 6 | 7 | 8 | , 9 | ); 10 | -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/components/InstallStep/InstallStep.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Icon from "@mdi/react"; 3 | import { mdiCheck, mdiClose, mdiLoading, mdiSlashForward } from "@mdi/js"; 4 | 5 | export const InstallStep = ({type, progressValue, text, imgContent}) => { 6 | const radius = 12; 7 | const circumference = 2 * Math.PI * radius; 8 | const progress = circumference - (progressValue / 100) * circumference; 9 | 10 | return ( 11 |
12 | {type === "image" &&
13 | img 14 |
} 15 | {type === "success" &&
16 | 17 |
} 18 | {type === "soon" &&
} 19 | {type === "skip" &&
20 | 21 |
} 22 | {type === "error" &&
23 | 24 |
} 25 | {type === "loading" &&
26 | 27 |
} 28 | {type === "progress" && ( 29 |
30 | 31 | 33 | 34 |
35 | )} 36 |

{text}

37 |
38 | ) 39 | } -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/components/InstallStep/index.js: -------------------------------------------------------------------------------- 1 | export {InstallStep as default} from "./InstallStep.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/components/InstallStep/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .install-step 4 | display: flex 5 | align-items: center 6 | gap: 1rem 7 | 8 | .indicator 9 | display: flex 10 | justify-content: center 11 | align-items: center 12 | width: 1.4rem 13 | height: 1.4rem 14 | padding: 0.3rem 15 | border-radius: 40% 16 | 17 | .progress-indicator 18 | background-color: colors.$primary-opacity 19 | svg 20 | width: 30px 21 | height: 30px 22 | 23 | .progress-circle 24 | stroke: colors.$primary 25 | stroke-width: 3 26 | stroke-linecap: round 27 | transform: rotate(-90deg) 28 | transform-origin: 50% 50% 29 | transition: stroke-dashoffset 0.35s ease 30 | 31 | .skip-indicator 32 | background-color: colors.$gray 33 | color: colors.$light-gray 34 | 35 | .image-indicator img 36 | height: 1.4rem 37 | 38 | .soon-indicator 39 | outline: 2px solid colors.$gray 40 | 41 | .success-indicator 42 | background-color: colors.$success-opacity 43 | color: colors.$success 44 | 45 | .error-indicator 46 | background-color: colors.$error-opacity 47 | color: colors.$error 48 | 49 | .loading-indicator 50 | background-color: colors.$primary-opacity 51 | color: colors.$primary 52 | 53 | h2 54 | font-size: 1.3rem 55 | font-weight: 600 56 | margin: 0 57 | -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/components/LogDialog/LogDialog.jsx: -------------------------------------------------------------------------------- 1 | import { DialogProvider } from "@/common/components/Dialog"; 2 | import "./styles.sass"; 3 | import { useEffect, useRef } from "react"; 4 | 5 | export const LogDialog = ({open, onClose, content}) => { 6 | 7 | const logRef = useRef(); 8 | 9 | useEffect(() => { 10 | if (logRef.current) { 11 | logRef.current.scrollTop = logRef.current.scrollHeight; 12 | } 13 | }, [content]); 14 | 15 | return ( 16 | 17 |
18 | 19 |
20 |

Installation Log

21 |
22 | 23 |
24 |
25 |                         {content}
26 |                     
27 |
28 |
29 |
30 | ); 31 | }; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/components/LogDialog/index.js: -------------------------------------------------------------------------------- 1 | export {LogDialog as default} from "./LogDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/components/LogDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .log-dialog 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | width: 80vw 8 | max-width: 30rem 9 | .log-dialog-header h2 10 | margin: 0 11 | 12 | .log-dialog-content 13 | width: 100% 14 | box-sizing: border-box 15 | overflow-wrap: break-word 16 | height: 15rem 17 | overflow-y: auto 18 | padding: 0 10px 19 | 20 | pre 21 | margin: 0 22 | white-space: pre-wrap 23 | font-size: 0.8rem 24 | font-weight: 400 -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/index.js: -------------------------------------------------------------------------------- 1 | export {AppInstaller as default} from "./AppInstaller.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/os_images/debian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/client/src/pages/Apps/components/AppInstaller/os_images/debian.png -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/os_images/linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/client/src/pages/Apps/components/AppInstaller/os_images/linux.png -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/os_images/ubuntu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/client/src/pages/Apps/components/AppInstaller/os_images/ubuntu.png -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppInstaller/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .app-installer 4 | display: flex 5 | flex-direction: column 6 | background-color: colors.$dark-gray 7 | border: 1px solid colors.$gray 8 | padding: 1rem 1.3rem 9 | border-radius: 1rem 10 | gap: 2rem 11 | 12 | p 13 | font-size: 1.1rem 14 | font-weight: 500 15 | 16 | .install-progress 17 | display: flex 18 | flex-direction: column 19 | gap: 0.5rem 20 | 21 | .install-actions 22 | display: flex 23 | gap: 1rem 24 | width: 100% 25 | 26 | button 27 | width: 100% 28 | 29 | .install-header 30 | display: flex 31 | align-items: center 32 | gap: 1rem 33 | 34 | .app-img 35 | background-color: colors.$dark-gray 36 | border: 1px solid colors.$gray 37 | border-radius: 1rem 38 | display: flex 39 | padding: 0.5rem 40 | align-items: center 41 | justify-content: center 42 | width: 3rem 43 | height: 3rem 44 | 45 | img 46 | width: 90% 47 | 48 | h2 49 | margin: 0 50 | 51 | p 52 | margin: 0 53 | font-weight: 600 54 | color: colors.$subtext -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppItem/AppItem.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Button from "@/common/components/Button"; 3 | import { mdiRocketLaunch } from "@mdi/js"; 4 | 5 | export const AppItem = ({ onClick, icon, version, title, description, installing }) => { 6 | return ( 7 |
8 |
9 |
10 | {title} 11 |
12 | 13 |
14 |

{title}

15 |

Version {version}

16 |
17 |
18 | 19 |

{description}

20 | 21 |
22 |
24 |
25 | ); 26 | }; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppItem/index.js: -------------------------------------------------------------------------------- 1 | export {AppItem as default} from "./AppItem.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppItem/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .app-item 4 | display: flex 5 | flex-direction: column 6 | background-color: colors.$dark-gray 7 | border: 1px solid colors.$gray 8 | padding: 1rem 1.3rem 9 | border-radius: 1rem 10 | width: 15rem 11 | 12 | p 13 | font-size: 1.1rem 14 | font-weight: 500 15 | 16 | 17 | .app-header 18 | display: flex 19 | align-items: center 20 | gap: 1rem 21 | 22 | .app-img 23 | background-color: colors.$dark-gray 24 | border: 1px solid colors.$gray 25 | border-radius: 1rem 26 | padding: 0.5rem 27 | display: flex 28 | align-items: center 29 | justify-content: center 30 | width: 3rem 31 | height: 3rem 32 | 33 | img 34 | width: 90% 35 | 36 | h2 37 | margin: 0 38 | 39 | p 40 | margin: 0 41 | font-weight: 600 42 | color: colors.$subtext 43 | 44 | .action-area 45 | display: flex 46 | align-items: center 47 | justify-content: end -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppNavigation/AppNavigation.jsx: -------------------------------------------------------------------------------- 1 | import Icon from "@mdi/react"; 2 | import "./styles.sass"; 3 | import { useLocation, useNavigate } from "react-router-dom"; 4 | import { 5 | mdiCloud, 6 | mdiCodeTags, 7 | mdiFolderMultipleImage, 8 | mdiLan, 9 | mdiMagnify, 10 | mdiPackageVariant, 11 | mdiWrench, 12 | } from "@mdi/js"; 13 | import ServerSearch from "@/pages/Servers/components/ServerList/components/ServerSearch"; 14 | 15 | export const AppNavigation = ({ search, setSearch }) => { 16 | 17 | const categories = [ 18 | { title: "Networking", icon: mdiLan }, 19 | { title: "Media", icon: mdiFolderMultipleImage }, 20 | { title: "Cloud", icon: mdiCloud }, 21 | { title: "Development", icon: mdiCodeTags }, 22 | { title: "Utilities", icon: mdiWrench }, 23 | ]; 24 | 25 | const location = useLocation(); 26 | const navigate = useNavigate(); 27 | 28 | const endsWith = (path) => { 29 | if (path === "/") return location.pathname === "/apps/" || location.pathname === "/apps"; 30 | return location.pathname.endsWith(path); 31 | }; 32 | 33 | const switchCategory = (category) => { 34 | setSearch(""); 35 | navigate("/apps/" + category.title.toLowerCase()); 36 | }; 37 | 38 | return ( 39 |
40 | 41 | 42 |
navigate("/apps/")}> 44 | 45 |

{search ? "Results" : "All"}

46 |
47 | 48 | {categories.map((category, index) => ( 49 |
switchCategory(category)}> 52 | 53 |

{category.title}

54 |
55 | ))} 56 |
57 | ); 58 | }; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppNavigation/index.js: -------------------------------------------------------------------------------- 1 | export { AppNavigation } from "./AppNavigation.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/AppNavigation/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .app-navigation 4 | height: 100% 5 | padding: 1rem 0.5rem 6 | background-color: colors.$lighter-background 7 | display: flex 8 | box-sizing: border-box 9 | flex-direction: column 10 | align-items: center 11 | border-right: 2px solid colors.$dark-gray 12 | user-select: none 13 | 14 | .settings-item 15 | box-sizing: border-box 16 | width: 100% 17 | padding: 0.75rem 1.5rem 18 | margin-top: 1rem 19 | display: flex 20 | gap: 0.8rem 21 | border-radius: 1rem 22 | border: 1px solid transparent 23 | cursor: pointer 24 | 25 | svg 26 | width: 2rem 27 | height: 2rem 28 | 29 | h2 30 | margin: 0 31 | 32 | &:hover 33 | border: 1px solid colors.$gray 34 | .settings-item-active 35 | background-color: colors.$dark-gray 36 | border: 1px solid colors.$gray -------------------------------------------------------------------------------- /client/src/pages/Apps/components/DeployServerDialog/DeployServerDialog.jsx: -------------------------------------------------------------------------------- 1 | import { DialogProvider } from "@/common/components/Dialog"; 2 | import { useContext } from "react"; 3 | import { ServerContext } from "@/common/contexts/ServerContext.jsx"; 4 | import ServerEntries from "@/pages/Servers/components/ServerList/components/ServerEntries.jsx"; 5 | import "./styles.sass"; 6 | 7 | export const DeployServerDialog = ({app, open, onClose, onDeploy}) => { 8 | 9 | const {servers} = useContext(ServerContext); 10 | 11 | const deployServer = (id) => { 12 | onClose(); 13 | onDeploy(id); 14 | } 15 | 16 | return ( 17 | 18 |
19 |
20 | {app?.name} 21 |

Deploy {app?.name}

22 |
23 |
24 | {servers?.length > 0 && } 25 | {servers?.length === 0 &&

No SSH servers available

} 26 |
27 |
28 |
29 | ) 30 | } -------------------------------------------------------------------------------- /client/src/pages/Apps/components/DeployServerDialog/index.js: -------------------------------------------------------------------------------- 1 | export {DeployServerDialog as default} from "./DeployServerDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/DeployServerDialog/styles.sass: -------------------------------------------------------------------------------- 1 | .deploy-dialog 2 | width: 15rem 3 | flex-direction: column 4 | 5 | .deploy-header 6 | margin-bottom: 1rem 7 | display: flex 8 | align-items: center 9 | 10 | h2 11 | margin: 0 12 | font-size: 1.2rem 13 | 14 | img 15 | height: 1.5rem 16 | margin-right: 0.5rem 17 | 18 | .deploy-entries 19 | overflow-y: scroll 20 | max-height: 20rem -------------------------------------------------------------------------------- /client/src/pages/Apps/components/SourceDialog/SourceDialog.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import { DialogProvider } from "@/common/components/Dialog"; 3 | import { useEffect, useState } from "react"; 4 | import { getRequest, postRequest } from "@/common/utils/RequestUtil.js"; 5 | import SourceItem from "@/pages/Apps/components/SourceDialog/components/SourceItem"; 6 | import Button from "@/common/components/Button"; 7 | 8 | export const SourceDialog = ({ open, onClose, refreshApps }) => { 9 | 10 | const [createNew, setCreateNew] = useState(false); 11 | 12 | const [sources, setSources] = useState([]); 13 | 14 | const fetchSources = async () => { 15 | try { 16 | const response = await getRequest("apps/sources"); 17 | setSources(response); 18 | } catch (error) { 19 | console.error(error); 20 | } 21 | }; 22 | 23 | const refreshSources = async () => { 24 | try { 25 | postRequest("apps/refresh").then(() => refreshApps()); 26 | onClose(); 27 | } catch (error) { 28 | console.error(error); 29 | } 30 | } 31 | 32 | useEffect(() => { 33 | if (open) { 34 | fetchSources(); 35 | setCreateNew(false); 36 | } 37 | }, [open]); 38 | 39 | return ( 40 | 41 |
42 |

App sources

43 | 44 |
45 | {sources.map((source) => ( 46 | 48 | ))} 49 | {createNew && } 50 |
51 | 52 |
53 |
56 |
57 |
58 | ); 59 | }; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/SourceDialog/components/SourceItem/index.js: -------------------------------------------------------------------------------- 1 | export {SourceItem as default} from "./SourceItem.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/SourceDialog/components/SourceItem/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .source-item 4 | display: flex 5 | flex-direction: column 6 | justify-content: center 7 | gap: 1rem 8 | background-color: colors.$dark-gray 9 | border: 1px solid colors.$gray 10 | padding: 1rem 11 | border-radius: 0.5rem 12 | width: 22rem 13 | 14 | .action-area .action-open 15 | cursor: pointer 16 | 17 | .action-area .action-delete 18 | cursor: pointer 19 | color: colors.$error 20 | 21 | .source-header 22 | display: flex 23 | justify-content: space-between 24 | align-items: center 25 | 26 | .header-official svg 27 | color: colors.$primary 28 | 29 | .source-info 30 | display: flex 31 | gap: 0.5rem 32 | align-items: center 33 | 34 | span 35 | color: colors.$subtext 36 | font-weight: 600 37 | 38 | .edit-area .error 39 | display: flex 40 | align-items: center 41 | justify-content: center 42 | padding: 0.5rem 1rem 43 | background-color: colors.$error-opacity 44 | border: 1px solid colors.$gray 45 | color: colors.$error 46 | border-radius: 0.5rem 47 | font-size: 0.9rem 48 | margin: 0 49 | 50 | .edit-row 51 | width: 100% 52 | display: flex 53 | align-items: center 54 | justify-content: space-between 55 | 56 | .input-container 57 | width: 15rem 58 | 59 | svg 60 | width: 2rem 61 | height: 2rem -------------------------------------------------------------------------------- /client/src/pages/Apps/components/SourceDialog/index.js: -------------------------------------------------------------------------------- 1 | export {SourceDialog as default} from "./SourceDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/SourceDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .source-dialog 4 | display: flex 5 | flex-direction: column 6 | overflow-y: scroll 7 | max-height: 20rem 8 | gap: 1rem 9 | h2 10 | margin: 0 11 | 12 | .source-list 13 | display: flex 14 | gap: 1rem 15 | flex-direction: column 16 | 17 | 18 | 19 | .btn-actions 20 | display: flex 21 | gap: 1rem 22 | 23 | button 24 | width: 100% -------------------------------------------------------------------------------- /client/src/pages/Apps/components/StoreHeader/StoreHeader.jsx: -------------------------------------------------------------------------------- 1 | import Icon from "@mdi/react"; 2 | import { mdiBook, mdiPackageVariant } from "@mdi/js"; 3 | import Button from "@/common/components/Button"; 4 | import "./styles.sass"; 5 | 6 | export const StoreHeader = ({onSourceClick}) => { 7 | return ( 8 |
9 |
10 | 11 |
12 |

App Store

13 |

Your favorite apps, deployed with a single click.

14 |
15 |
16 | 17 |
19 | ) 20 | } -------------------------------------------------------------------------------- /client/src/pages/Apps/components/StoreHeader/index.js: -------------------------------------------------------------------------------- 1 | export {StoreHeader as default} from "./StoreHeader.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/components/StoreHeader/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .store-header 4 | display: flex 5 | justify-content: space-between 6 | align-items: center 7 | margin: 1.5rem 2rem 8 | 9 | .store-title 10 | display: flex 11 | align-items: center 12 | gap: 1rem 13 | 14 | svg 15 | width: 4rem 16 | height: 4rem 17 | 18 | h1 19 | margin: 0 20 | font-size: 1.8rem 21 | 22 | p 23 | margin: 0 24 | font-size: 1rem 25 | font-weight: 600 26 | color: colors.$subtext -------------------------------------------------------------------------------- /client/src/pages/Apps/index.js: -------------------------------------------------------------------------------- 1 | export {Apps as default} from "./Apps.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Apps/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .apps-page 4 | display: grid 5 | grid-template-columns: 18rem 1fr 6 | user-select: none 7 | height: 100vh 8 | 9 | .app-grid 10 | display: grid 11 | grid-template-columns: 1fr 25rem 12 | 13 | .app-list 14 | height: calc(100vh - 12rem) 15 | margin: 2.5rem 2rem 16 | display: flex 17 | align-content: flex-start 18 | align-items: flex-start 19 | gap: 2rem 20 | flex-wrap: wrap 21 | overflow-y: scroll 22 | 23 | .no-apps 24 | display: flex 25 | align-items: center 26 | justify-content: center 27 | flex-direction: column 28 | width: 100% 29 | height: 100% 30 | 31 | svg 32 | width: 4rem 33 | height: 4rem 34 | margin-right: 1rem 35 | 36 | h2 37 | margin: 0 38 | text-align: center 39 | width: 19rem 40 | 41 | .app-details 42 | display: flex 43 | justify-content: center 44 | align-items: center 45 | height: 100% 46 | 47 | .select-app 48 | display: flex 49 | flex-direction: column 50 | gap: 1rem 51 | align-items: center 52 | 53 | svg 54 | width: 8rem 55 | height: 8rem 56 | 57 | h3 58 | text-align: center 59 | margin: 0 60 | max-width: 7rem 61 | 62 | 63 | @media screen and (max-width: 1200px) 64 | .apps-page .app-grid 65 | display: flex 66 | flex-direction: column-reverse 67 | 68 | .apps-page .app-list 69 | margin: 2.5rem 2rem 70 | overflow-y: auto 71 | height: auto 72 | 73 | .apps-page .select-app 74 | display: none 75 | 76 | .apps-page .app-details 77 | display: flex 78 | height: 100% 79 | 80 | .app-installer 81 | width: 100% 82 | margin: 2.5rem 2rem 83 | 84 | 85 | .apps-page 86 | overflow: scroll 87 | -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ProxmoxDialog/index.js: -------------------------------------------------------------------------------- 1 | export {ProxmoxDialog as default} from "./ProxmoxDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ProxmoxDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .proxmox-dialog 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | width: 20rem 8 | 9 | h2, p 10 | margin: 0 11 | 12 | p 13 | font-weight: 600 14 | font-size: 0.9rem 15 | 16 | .error 17 | border: 1px solid colors.$gray 18 | 19 | .form-group 20 | display: flex 21 | flex-direction: column 22 | gap: 0.3rem 23 | 24 | label 25 | color: colors.$subtext 26 | font-weight: 600 27 | 28 | .ip-row 29 | display: flex 30 | gap: 1rem 31 | 32 | .small-input 33 | padding: 0.8rem 34 | border: 1px solid colors.$gray 35 | width: 3rem 36 | background-color: colors.$dark-gray 37 | color: colors.$light-gray 38 | border-radius: 0.5rem 39 | font-size: 14pt 40 | outline: none 41 | 42 | &:focus 43 | border: 1px solid colors.$primary 44 | background-color: colors.$gray -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerDialog/index.js: -------------------------------------------------------------------------------- 1 | export {ServerDialog as default} from "./ServerDialog"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .server-dialog 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | user-select: none 8 | width: 28rem 9 | 10 | .text-center 11 | text-align: center 12 | 13 | .server-dialog-content 14 | display: flex 15 | flex-direction: column 16 | gap: 0.5rem 17 | 18 | .name-row 19 | display: grid 20 | grid-template-columns: 4fr 1fr 21 | gap: 1rem 22 | 23 | .address-row 24 | display: flex 25 | gap: 1rem 26 | 27 | .password-row 28 | width: 100% 29 | 30 | .keyfile-row 31 | display: flex 32 | gap: 1rem 33 | 34 | input[type="file"] 35 | cursor: pointer 36 | 37 | input[type="file"]::-webkit-file-upload-button 38 | display: none 39 | 40 | 41 | .form-group 42 | display: flex 43 | flex-direction: column 44 | gap: 0.3rem 45 | 46 | label 47 | color: colors.$subtext 48 | font-weight: 600 49 | 50 | .small-input 51 | padding: 0.8rem 52 | width: 3rem 53 | border: 1px solid colors.$gray 54 | background-color: colors.$dark-gray 55 | color: colors.$light-gray 56 | border-radius: 0.5rem 57 | font-size: 14pt 58 | outline: none 59 | 60 | &:focus 61 | border: 1px solid colors.$primary 62 | background-color: colors.$gray 63 | 64 | .server-dialog-title 65 | h2 66 | font-size: 1.3rem 67 | margin: 0 68 | 69 | 70 | .server-dialog-tabs 71 | display: flex 72 | gap: 1rem 73 | 74 | .tabs-item 75 | padding: 0.5rem 1rem 76 | border-bottom: 2px solid transparent 77 | cursor: pointer 78 | border-radius: 0.5rem 79 | 80 | h3 81 | margin: 0 82 | font-weight: 600 83 | 84 | &:hover 85 | background-color: colors.$darker-gray 86 | 87 | .tabs-item-active 88 | background-color: colors.$gray 89 | border-bottom: 2px solid colors.$primary 90 | border-radius: 0.5rem 0.5rem 0 0 91 | 92 | &:hover 93 | background-color: colors.$gray 94 | 95 | .identities 96 | display: flex 97 | flex-direction: column 98 | gap: 1rem 99 | 100 | .identity 101 | display: flex 102 | background-color: colors.$dark-gray 103 | border: 1px solid colors.$gray 104 | padding: 1rem 105 | border-radius: 0.5rem 106 | flex-direction: column 107 | gap: 0.5rem 108 | 109 | .identity-header h3 110 | margin: 0 -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/CollapsibleFolder.jsx: -------------------------------------------------------------------------------- 1 | import FolderObject from "@/pages/Servers/components/ServerList/components/FolderObject"; 2 | import ServerEntries from "./ServerEntries.jsx"; 3 | import { useState } from "react"; 4 | 5 | const CollapsibleFolder = ({ id, name, entries, nestedLevel, renameState, setRenameStateId, connectToServer, connectToPVEServer, sshOnly }) => { 6 | const [isOpen, setIsOpen] = useState(true); 7 | const toggleFolder = () => setIsOpen(!isOpen); 8 | 9 | return ( 10 | <> 11 | 13 | {isOpen && ( 14 | 16 | )} 17 | 18 | ); 19 | }; 20 | 21 | export default CollapsibleFolder; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/ContextMenu/assets/proxmox.jsx: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return ( 3 | 4 | 5 | 6 | ) 7 | } -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/ContextMenu/index.js: -------------------------------------------------------------------------------- 1 | export {ContextMenu as default} from "./ContextMenu.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/ContextMenu/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .context-menu 4 | background-color: colors.$darker-gray 5 | backdrop-filter: blur(1rem) 6 | border: 2px solid colors.$dark-gray 7 | border-radius: 0.5rem 8 | padding: 0.5rem 9 | position: fixed 10 | z-index: 10000 11 | 12 | .context-item 13 | color: colors.$white 14 | padding: 0.5rem 1rem 15 | cursor: pointer 16 | transition: background-color 0.2s 17 | border-radius: 0.5rem 18 | user-select: none 19 | display: flex 20 | align-items: center 21 | 22 | svg 23 | width: 1rem 24 | height: 1rem 25 | margin-right: 0.5rem 26 | 27 | img 28 | width: 1rem 29 | height: 1rem 30 | margin-right: 0.5rem 31 | 32 | p 33 | margin: 0 34 | 35 | &:hover 36 | background-color: colors.$dark-gray -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/FolderObject/index.js: -------------------------------------------------------------------------------- 1 | export {FolderObject as default} from "./FolderObject"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/FolderObject/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .folder-object 4 | display: flex 5 | gap: 0.5rem 6 | color: colors.$light-gray 7 | border: 1px solid transparent 8 | align-items: center 9 | padding: 0.4rem 10 | font-weight: 600 11 | cursor: pointer 12 | 13 | svg 14 | width: 1.5rem 15 | 16 | p 17 | margin: 0 18 | 19 | &:hover 20 | color: colors.$white 21 | background-color: colors.$dark-gray 22 | border: 1px solid colors.$gray 23 | border-radius: 0.3rem 24 | 25 | input 26 | padding: 0.4rem 0.6rem 27 | border: 1px solid colors.$gray 28 | background-color: transparent 29 | color: colors.$light-gray 30 | box-sizing: border-box 31 | border-radius: 0.7rem 32 | font-size: 12pt 33 | width: 100% 34 | outline: none 35 | 36 | .folder-is-over 37 | background-color: colors.$dark-gray 38 | border: 1px solid colors.$gray 39 | border-radius: 0.3rem -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/OrganizationFolder/OrganizationFolder.jsx: -------------------------------------------------------------------------------- 1 | import Icon from "@mdi/react"; 2 | import { mdiDomain, mdiDomainOff } from "@mdi/js"; 3 | import ServerEntries from "../ServerEntries.jsx"; 4 | import { useState } from "react"; 5 | 6 | const OrganizationFolder = ({ id, name, entries, nestedLevel, connectToServer, connectToPVEServer, setRenameStateId, sshOnly }) => { 7 | const [isOpen, setIsOpen] = useState(true); 8 | const toggleFolder = () => setIsOpen(!isOpen); 9 | const orgId = id.split('-')[1]; 10 | 11 | return ( 12 | <> 13 |
15 | 16 |

{name}

17 |
18 | {isOpen && ( 19 | 28 | )} 29 | 30 | ); 31 | }; 32 | 33 | export default OrganizationFolder; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/OrganizationFolder/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./OrganizationFolder.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/PVEObject/PVEObject.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import ProxmoxIcon from "./assets/proxmox.png"; 3 | import ServerObject from "@/pages/Servers/components/ServerList/components/ServerObject"; 4 | import { useState } from "react"; 5 | import { mdiConsoleLine, mdiLinux, mdiMonitor, mdiServerOutline } from "@mdi/js"; 6 | 7 | export const getIconByType = (type) => { 8 | switch (type) { 9 | case "pve-shell": 10 | return mdiConsoleLine; 11 | case "pve-lxc": 12 | return mdiLinux; 13 | case "pve-vm": 14 | return mdiMonitor; 15 | default: 16 | return mdiServerOutline; 17 | } 18 | }; 19 | 20 | 21 | export const PVEObject = ({ nestedLevel, name, id, online, entries, connectToPVEServer }) => { 22 | 23 | const [isOpen, setIsOpen] = useState(true); 24 | 25 | return ( 26 | <> 27 |
setIsOpen(!isOpen)}> 29 | Proxmox 30 |

{name}

31 |
32 | 33 | {isOpen && online && entries.map(entry => connectToPVEServer(id, containerId)} 39 | isPVE />)} 40 | 41 | 42 | ); 43 | }; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/PVEObject/assets/proxmox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/client/src/pages/Servers/components/ServerList/components/PVEObject/assets/proxmox.png -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/PVEObject/index.js: -------------------------------------------------------------------------------- 1 | export {PVEObject as default} from "./PVEObject.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/PVEObject/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .pve-object 4 | display: flex 5 | gap: 0.5rem 6 | color: colors.$light-gray 7 | border: 1px solid transparent 8 | padding: 0.4rem 9 | font-weight: 600 10 | cursor: pointer 11 | 12 | img 13 | width: 1.5rem 14 | 15 | p 16 | margin: 0 17 | 18 | &:hover 19 | color: colors.$white 20 | background-color: colors.$dark-gray 21 | border: 1px solid colors.$gray 22 | border-radius: 0.3rem 23 | 24 | input 25 | padding: 0.4rem 0.6rem 26 | border: 1px solid colors.$gray 27 | background-color: transparent 28 | color: colors.$light-gray 29 | box-sizing: border-box 30 | border-radius: 0.7rem 31 | font-size: 12pt 32 | width: 100% 33 | outline: none 34 | 35 | .pve-offline 36 | filter: grayscale(100%) -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/ServerObject/index.js: -------------------------------------------------------------------------------- 1 | export {ServerObject as default} from "./ServerObject"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/ServerObject/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .server-object 4 | display: flex 5 | align-items: center 6 | gap: 0.5rem 7 | color: colors.$light-gray 8 | border: 1px solid transparent 9 | padding: 0.4rem 10 | cursor: pointer 11 | font-weight: 600 12 | 13 | .system-icon 14 | width: 1.5rem 15 | height: 1.5rem 16 | display: flex 17 | border-radius: 0.2rem 18 | justify-content: center 19 | align-items: center 20 | background-color: colors.$primary 21 | svg 22 | color: white 23 | width: 1rem 24 | 25 | .pve-icon 26 | background-color: colors.$warning 27 | 28 | .pve-icon-offline 29 | background-color: colors.$gray 30 | 31 | p 32 | margin: 0 33 | flex: 1 34 | 35 | &:hover 36 | color: colors.$white 37 | background-color: colors.$dark-gray 38 | border: 1px solid colors.$gray 39 | border-radius: 0.3rem 40 | 41 | .server-is-over 42 | background-color: colors.$dark-gray 43 | border: 1px solid colors.$gray 44 | border-radius: 0.3rem 45 | -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/ServerSearch/ServerSearch.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Icon from "@mdi/react"; 3 | import { mdiMagnify } from "@mdi/js"; 4 | import { useEffect, useRef } from "react"; 5 | 6 | export const ServerSearch = ({search, setSearch}) => { 7 | 8 | const inputRef = useRef(null); 9 | 10 | useEffect(() => { 11 | const handleKeyDown = (e) => { 12 | if (e.ctrlKey && e.key === "s") { 13 | e.preventDefault(); 14 | inputRef.current.focus(); 15 | } 16 | } 17 | document.addEventListener("keydown", handleKeyDown); 18 | return () => { 19 | document.removeEventListener("keydown", handleKeyDown); 20 | } 21 | }, []); 22 | 23 | return ( 24 |
25 | 26 | setSearch(e.target.value)} /> 28 |
inputRef.current.focus()}> 29 |

CTRL + S

30 |
31 |
32 | ) 33 | } -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/ServerSearch/index.js: -------------------------------------------------------------------------------- 1 | export {ServerSearch as default} from "./ServerSearch.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/components/ServerSearch/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .server-search 4 | margin-top: 1rem 5 | position: relative 6 | width: 100% 7 | 8 | .search-icon 9 | position: absolute 10 | top: 50% 11 | left: 0.75rem 12 | width: 2rem 13 | height: 2rem 14 | transform: translateY(-50%) 15 | color: colors.$subtext 16 | 17 | .info-container 18 | position: absolute 19 | top: 50% 20 | right: 0.75rem 21 | transform: translateY(-50%) 22 | user-select: none 23 | cursor: pointer 24 | color: colors.$subtext 25 | 26 | p 27 | margin: 0 28 | padding: 0.2rem 0.5rem 29 | border-radius: 0.5rem 30 | font-size: 10pt 31 | border: 1px solid colors.$gray 32 | 33 | .search-input 34 | user-select: none 35 | padding: 0.8rem 6rem 0.8rem 3.5rem 36 | border: 1px solid colors.$gray 37 | background-color: colors.$dark-gray 38 | color: colors.$light-gray 39 | box-sizing: border-box 40 | border-radius: 0.7rem 41 | font-size: 14pt 42 | width: 100% 43 | outline: none 44 | 45 | &:focus 46 | border: 1px solid colors.$primary 47 | background-color: colors.$gray -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/index.js: -------------------------------------------------------------------------------- 1 | export {ServerList as default} from "./ServerList.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ServerList/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .server-list 4 | background-color: colors.$lighter-background 5 | height: 100% 6 | display: flex 7 | flex-direction: column 8 | align-items: center 9 | position: relative 10 | border-right: 2px solid colors.$dark-gray 11 | overflow: hidden 12 | 13 | &.collapsed 14 | width: 6px 15 | padding: 0 16 | border-right-width: 2px 17 | cursor: e-resize 18 | 19 | .server-list-inner 20 | display: flex 21 | flex-direction: column 22 | width: 95% 23 | height: 100% 24 | overflow: hidden 25 | 26 | .servers 27 | margin-top: 1rem 28 | display: flex 29 | flex-direction: column 30 | gap: 0.2rem 31 | user-select: none 32 | flex: 1 33 | overflow-x: hidden 34 | overflow-y: auto 35 | 36 | &::-webkit-scrollbar 37 | width: 4px 38 | height: 4px 39 | 40 | &::-webkit-scrollbar-track 41 | background: transparent 42 | 43 | &::-webkit-scrollbar-thumb 44 | background: colors.$gray 45 | border-radius: 10px 46 | 47 | &::-webkit-scrollbar-thumb:hover 48 | background: colors.$subtext 49 | 50 | scrollbar-width: thin 51 | scrollbar-color: colors.$gray transparent 52 | 53 | .truncate-text 54 | white-space: nowrap 55 | overflow: hidden 56 | text-overflow: ellipsis 57 | max-width: 80% 58 | 59 | .no-servers 60 | display: flex 61 | user-select: none 62 | height: 100% 63 | flex-direction: column 64 | justify-content: center 65 | align-items: center 66 | color: colors.$subtext 67 | 68 | svg 69 | width: 5rem 70 | height: 5rem 71 | 72 | .resizer 73 | position: absolute 74 | right: -4px 75 | top: 0 76 | height: 100% 77 | width: 8px 78 | background-color: transparent 79 | cursor: col-resize 80 | z-index: 10 81 | 82 | &:hover 83 | background-color: colors.$primary 84 | .is-resizing 85 | background-color: colors.$primary -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/components/ServerTabs/ServerTabs.jsx: -------------------------------------------------------------------------------- 1 | import { ServerContext } from "@/common/contexts/ServerContext.jsx"; 2 | import { useContext } from "react"; 3 | import Icon from "@mdi/react"; 4 | import { loadIcon } from "@/pages/Servers/components/ServerList/components/ServerObject/ServerObject.jsx"; 5 | import { getIconByType} from "@/pages/Servers/components/ServerList/components/PVEObject/PVEObject.jsx"; 6 | import { mdiClose, mdiViewSplitVertical } from "@mdi/js"; 7 | import "./styles.sass"; 8 | 9 | export const ServerTabs = ({activeSessions, setActiveSessionId, activeSessionId, disconnectFromServer}) => { 10 | 11 | const {getServerById, getPVEContainerById} = useContext(ServerContext); 12 | 13 | return ( 14 |
15 | alert("Not implemented yet")} /> 16 |
17 | {activeSessions.map(session => { 18 | let server = session.containerId === undefined ? getServerById(session.server) : getPVEContainerById(session.server, session.containerId); 19 | 20 | return ( 21 |
setActiveSessionId(session.id)}> 23 | 24 |

{server?.name} {session.type === "sftp" ? " (SFTP)" : ""}

25 | { 26 | e.stopPropagation(); 27 | disconnectFromServer(session.id); 28 | }} /> 29 |
30 | ) 31 | })} 32 |
33 |
34 | 35 | ) 36 | } -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/components/ServerTabs/index.js: -------------------------------------------------------------------------------- 1 | export {ServerTabs as default} from "./ServerTabs.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/components/ServerTabs/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .server-tabs 4 | display: flex 5 | padding: 0.7rem 1rem 0 1rem 6 | gap: 1rem 7 | align-items: center 8 | user-select: none 9 | overflow: hidden 10 | 11 | svg 12 | width: 2rem 13 | height: 2rem 14 | 15 | .tabs 16 | display: flex 17 | gap: 0.5rem 18 | 19 | 20 | .server-tab 21 | display: flex 22 | gap: 0.5rem 23 | padding: 0.5rem 1rem 24 | align-items: center 25 | border-radius: 0.5rem 0.5rem 0 0 26 | border-bottom: 2px solid transparent 27 | cursor: pointer 28 | overflow: hidden 29 | white-space: nowrap 30 | 31 | &:hover 32 | background-color: colors.$gray 33 | border-bottom: 2px solid colors.$primary-opacity 34 | 35 | svg 36 | width: 1.5rem 37 | height: 1.5rem 38 | 39 | h2 40 | margin: 0 41 | font-size: 1.3rem 42 | 43 | .server-tab-active 44 | background-color: colors.$gray 45 | border-bottom: 2px solid colors.$primary 46 | 47 | &:hover 48 | background-color: colors.$gray 49 | border-bottom: 2px solid colors.$primary -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/index.js: -------------------------------------------------------------------------------- 1 | export {ViewContainer as default} from "./ViewContainer"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/ActionBar.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Icon from "@mdi/react"; 3 | import { mdiChevronLeft, mdiChevronRight, mdiChevronUp, mdiFileUpload, mdiFolderPlus } from "@mdi/js"; 4 | import { Fragment } from "react"; 5 | 6 | export const ActionBar = ({ path, updatePath, createFolder, uploadFile, goBack, goForward, historyIndex, historyLength }) => { 7 | 8 | const goUp = () => { 9 | const pathArray = path.split("/"); 10 | pathArray.pop(); 11 | 12 | if (pathArray.length === 1 && pathArray[0] === "") { 13 | pathArray.pop(); 14 | } 15 | 16 | updatePath(pathArray.length === 0 ? "/" : pathArray.join("/")); 17 | }; 18 | 19 | const navigate = (part) => { 20 | const pathArray = getPathArray(); 21 | updatePath("/" + pathArray.slice(0, part + 1).join("/")); 22 | }; 23 | 24 | const getPathArray = () => { 25 | return path.split("/").filter(part => part !== ""); 26 | }; 27 | 28 | return ( 29 |
30 | 31 | 32 | 33 | 34 |
35 |
updatePath("/")}>/
36 | {getPathArray().map((part, index) => ( 37 | 38 |
navigate(index)}> 39 | {part} 40 |
41 |
/
42 |
43 | ))} 44 |
45 | 46 |
47 | 48 | 49 |
50 |
51 | ); 52 | }; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/index.js: -------------------------------------------------------------------------------- 1 | export {ActionBar as default} from "./ActionBar.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .action-bar 4 | display: flex 5 | margin: 0 2rem 6 | padding-top: 1.5rem 7 | align-items: center 8 | 9 | svg 10 | width: 2rem 11 | height: 2rem 12 | cursor: pointer 13 | transition: color 0.2s 14 | 15 | &:hover 16 | color: colors.$primary 17 | 18 | .nav-disabled 19 | color: colors.$gray 20 | cursor: not-allowed 21 | 22 | &:hover 23 | color: colors.$gray 24 | 25 | .address-bar 26 | user-select: none 27 | padding: 0.5rem 1rem 28 | background-color: colors.$dark-gray 29 | border: 1px solid colors.$gray 30 | border-radius: 0.5rem 31 | display: flex 32 | gap: 0.5rem 33 | width: 100% 34 | margin: 0 1rem 35 | 36 | .path-part-divider 37 | color: colors.$subtext 38 | cursor: pointer 39 | 40 | .path-part 41 | cursor: pointer 42 | 43 | *:hover 44 | text-decoration: underline 45 | 46 | .file-actions 47 | display: flex 48 | gap: 1rem 49 | margin-right: 1rem -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/CreateFolderDialog.jsx: -------------------------------------------------------------------------------- 1 | import { DialogProvider } from "@/common/components/Dialog"; 2 | import { useEffect, useState } from "react"; 3 | import Button from "@/common/components/Button"; 4 | import IconInput from "@/common/components/IconInput"; 5 | import { mdiFolder } from "@mdi/js"; 6 | import "./styles.sass"; 7 | 8 | export const CreateFolderDialog = ({ open, onClose, createFolder }) => { 9 | 10 | const [folderName, setFolderName] = useState(""); 11 | 12 | const create = () => { 13 | if (folderName === "") return; 14 | if (folderName.includes("/")) return; 15 | 16 | createFolder(folderName); 17 | onClose(); 18 | } 19 | 20 | useEffect(() => { 21 | if (open) setFolderName(""); 22 | }, [open]); 23 | 24 | return ( 25 | 26 |
27 |

Create Folder

28 | 29 |
30 |
32 |
33 |
34 | ); 35 | }; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/index.js: -------------------------------------------------------------------------------- 1 | export {CreateFolderDialog as default} from "./CreateFolderDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/styles.sass: -------------------------------------------------------------------------------- 1 | .folder-dialog 2 | display: flex 3 | flex-direction: column 4 | gap: 1rem 5 | 6 | h2 7 | margin: 0 8 | 9 | .btn-actions 10 | display: flex 11 | justify-content: flex-end -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/index.js: -------------------------------------------------------------------------------- 1 | export {FileEditor as default} from "./FileEditor.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .file-editor 4 | margin: 0 2rem 5 | padding-top: 1rem 6 | 7 | .cm-editor 8 | margin-top: 0.5rem 9 | border-radius: 0.5rem 10 | height: calc(100dvh - 8rem) 11 | 12 | .cm-scroller 13 | border-radius: 0.5rem 14 | 15 | .file-header 16 | user-select: none 17 | display: flex 18 | align-items: center 19 | justify-content: space-between 20 | 21 | .file-name 22 | display: flex 23 | align-items: center 24 | gap: 0.5rem 25 | 26 | svg 27 | width: 2rem 28 | height: 2rem 29 | 30 | 31 | 32 | h2 33 | margin: 0 34 | 35 | .file-actions 36 | display: flex 37 | gap: 0.5rem 38 | 39 | svg 40 | width: 2rem 41 | height: 2rem 42 | cursor: pointer 43 | transition: color 0.2s 44 | 45 | &:hover 46 | color: colors.$primary 47 | 48 | .icon-disabled 49 | display: none -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/ContextMenu/index.js: -------------------------------------------------------------------------------- 1 | export {ContextMenu as default} from "./ContextMenu.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/RenameItemDialog.jsx: -------------------------------------------------------------------------------- 1 | import Button from "@/common/components/Button"; 2 | import { DialogProvider } from "@/common/components/Dialog"; 3 | import IconInput from "@/common/components/IconInput"; 4 | import { mdiFile, mdiFolder } from "@mdi/js"; 5 | import { useEffect, useState } from "react"; 6 | import "./styles.sass"; 7 | 8 | export const RenameItemDialog = ({ open, closeDialog, renameItem, item }) => { 9 | const [newName, setNewName] = useState(item?.name || ""); 10 | 11 | const handleRename = () => { 12 | renameItem(newName); 13 | closeDialog(); 14 | }; 15 | 16 | useEffect(() => { 17 | setNewName(item?.name || ""); 18 | }, [item]); 19 | 20 | return ( 21 | 22 |
23 |

Rename Item

24 | setNewName(e.target.value)} 29 | /> 30 |
31 |
34 |
35 |
36 | ); 37 | } -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/index.js: -------------------------------------------------------------------------------- 1 | export {RenameItemDialog as default} from "./RenameItemDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/styles.sass: -------------------------------------------------------------------------------- 1 | .rename-item-dialog 2 | display: flex 3 | flex-direction: column 4 | gap: 1rem 5 | 6 | h2 7 | margin: 0 8 | 9 | .action-area 10 | display: flex 11 | align-items: center 12 | justify-content: end 13 | gap: 1rem -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/index.js: -------------------------------------------------------------------------------- 1 | export {FileList as default} from "./FileList.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .file-list 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | overflow-y: scroll 8 | height: 100% 9 | margin-top: 1rem 10 | 11 | .file-item 12 | user-select: none 13 | margin: 0 2rem 14 | display: grid 15 | border-radius: 0.7rem 16 | grid-template-columns: 1fr 1fr 1fr 17 | align-items: center 18 | padding: 0.5rem 1rem 19 | background-color: colors.$dark-gray 20 | cursor: pointer 21 | 22 | .file-name 23 | display: flex 24 | gap: 1rem 25 | align-items: center 26 | 27 | h2, p 28 | margin: 0 29 | 30 | svg 31 | width: 2rem 32 | height: 2rem 33 | 34 | *:last-child 35 | grid-column: 5 / 5 -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/index.js: -------------------------------------------------------------------------------- 1 | export {FileRenderer as default} from "./FileRenderer.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .file-renderer 4 | height: 100% 5 | width: 100% 6 | 7 | .drag-overlay 8 | position: absolute 9 | width: calc(100vw - 23rem) 10 | height: calc(100% - 3.5rem) 11 | display: flex 12 | justify-content: center 13 | align-items: center 14 | background-color: rgba(0, 0, 0, 0.5) 15 | backdrop-filter: blur(0.3rem) 16 | color: colors.$white 17 | font-size: 1.5rem 18 | z-index: 1 19 | 20 | .drag-item 21 | padding: 4rem 2rem 22 | width: 20rem 23 | border: 0.2rem dashed colors.$white 24 | border-radius: 0.5rem 25 | display: flex 26 | flex-direction: column 27 | align-items: center 28 | gap: 2rem 29 | 30 | h2 31 | margin: 0 32 | font-weight: 600 33 | text-align: center 34 | 35 | svg 36 | width: 8rem 37 | 38 | 39 | .file-manager 40 | height: calc(100% - 0.5rem) 41 | display: flex 42 | flex-direction: column 43 | 44 | .upload-progress 45 | height: 0.5rem 46 | border-top-left-radius: 0.1rem 47 | border-top-right-radius: 0.1rem 48 | background-color: colors.$primary -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/components/SnippetsMenu/index.js: -------------------------------------------------------------------------------- 1 | export { SnippetsMenu as default } from "./SnippetsMenu.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/renderer/styles/xterm.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .xterm-container 4 | position: relative 5 | width: 100% 6 | height: 100% 7 | 8 | .xterm-wrapper 9 | width: 100% 10 | height: 98% 11 | 12 | .snippets-button 13 | position: absolute 14 | top: 10px 15 | right: 10px 16 | width: 36px 17 | height: 36px 18 | border-radius: 4px 19 | background-color: colors.$dark-gray 20 | border: 1px solid colors.$gray 21 | display: flex 22 | align-items: center 23 | justify-content: center 24 | cursor: pointer 25 | z-index: 100 26 | transition: background-color 0.2s ease, opacity 0.2s ease 27 | 28 | svg 29 | width: 20px 30 | height: 20px 31 | color: colors.$white 32 | 33 | &:hover 34 | background-color: colors.$primary 35 | 36 | &.hidden 37 | opacity: 0 38 | pointer-events: none 39 | 40 | .xterm-screen 41 | padding: 0.5rem -------------------------------------------------------------------------------- /client/src/pages/Servers/components/ViewContainer/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .view-container 4 | display: flex 5 | flex-direction: column 6 | height: 100vh 7 | width: 100% 8 | flex: 1 9 | overflow: hidden 10 | 11 | .view 12 | display: none 13 | background-color: colors.$terminal 14 | height: 100% 15 | width: 100% 16 | 17 | .view-layouter 18 | display: flex 19 | flex-direction: column 20 | height: calc(100vh - 3.5rem) 21 | width: 100% 22 | 23 | .view-active 24 | display: block -------------------------------------------------------------------------------- /client/src/pages/Servers/index.js: -------------------------------------------------------------------------------- 1 | export {Servers as default} from "./Servers.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Servers/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .server-page 4 | display: flex 5 | width: 100% 6 | height: 100% 7 | 8 | .welcome-area 9 | user-select: none 10 | overflow-y: scroll 11 | flex: 1 12 | display: flex 13 | align-items: center 14 | justify-content: space-around 15 | 16 | .area-left 17 | width: 30% 18 | 19 | .button-area 20 | display: flex 21 | align-items: center 22 | gap: 1rem 23 | 24 | span 25 | color: colors.$primary 26 | 27 | h1 28 | margin: 0 29 | 30 | p 31 | margin: 0.7rem 0 1.5rem 0 32 | color: colors.$subtext 33 | font-size: 16pt 34 | font-weight: 600 35 | 36 | img 37 | width: 40% -------------------------------------------------------------------------------- /client/src/pages/Settings/Settings.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Icon from "@mdi/react"; 3 | import { mdiAccountCircleOutline, mdiAccountGroup, mdiClockStarFourPointsOutline, mdiShieldAccountOutline, mdiDomain } from "@mdi/js"; 4 | import SettingsNavigation from "./components/SettingsNavigation"; 5 | import { Navigate, useLocation } from "react-router-dom"; 6 | import Account from "@/pages/Settings/pages/Account"; 7 | import Sessions from "@/pages/Settings/pages/Sessions"; 8 | import Users from "@/pages/Settings/pages/Users"; 9 | import Authentication from "@/pages/Settings/pages/Authentication"; 10 | import Organizations from "@/pages/Settings/pages/Organizations"; 11 | 12 | export const Settings = () => { 13 | const location = useLocation(); 14 | 15 | const userPages = [ 16 | { title: "Account", icon: mdiAccountCircleOutline, content: }, 17 | { title: "Sessions", icon: mdiClockStarFourPointsOutline, content: }, 18 | { title: "Organizations", icon: mdiDomain, content: } 19 | ]; 20 | 21 | const adminPages = [ 22 | { title: "Users", icon: mdiAccountGroup, content: }, 23 | { title: "Authentication", icon: mdiShieldAccountOutline, content: } 24 | ]; 25 | 26 | const currentPage = [...userPages, ...adminPages].find(page => location.pathname.endsWith(page.title.toLowerCase())); 27 | 28 | if (!currentPage) return ; 29 | 30 | return ( 31 |
32 | 33 |
34 |
35 | 36 |

{currentPage.title}

37 |
38 |
39 | 40 |
41 | {currentPage.content} 42 |
43 |
44 |
45 | ) 46 | } -------------------------------------------------------------------------------- /client/src/pages/Settings/components/SettingsNavigation/SettingsNavigation.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import SettingsItem from "./components/SettingsItem"; 3 | import { useContext } from "react"; 4 | import { UserContext } from "@/common/contexts/UserContext.jsx"; 5 | 6 | export const SettingsNavigation = ({ userPages, adminPages }) => { 7 | 8 | const { user } = useContext(UserContext); 9 | 10 | return ( 11 |
12 |

USER SETTINGS

13 | 14 |
15 | {userPages.map((page, index) => ( 16 | 17 | ))} 18 |
19 | 20 | {user?.role === "admin" &&

ADMIN SETTINGS

} 21 | {user?.role === "admin" &&
22 | {adminPages.map((page, index) => ( 23 | 24 | ))} 25 |
} 26 |
27 | ); 28 | }; -------------------------------------------------------------------------------- /client/src/pages/Settings/components/SettingsNavigation/components/SettingsItem/SettingsItem.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Icon from "@mdi/react"; 3 | import { useLocation, useNavigate } from "react-router-dom"; 4 | 5 | export const SettingsItem = ({icon, title}) => { 6 | 7 | const location = useLocation(); 8 | const navigate = useNavigate(); 9 | 10 | const endsWith = (path) => { 11 | return location.pathname.endsWith(path); 12 | } 13 | 14 | return ( 15 |
navigate("/settings/" + title.toLowerCase())}> 17 | 18 |

{title}

19 |
20 | ) 21 | } -------------------------------------------------------------------------------- /client/src/pages/Settings/components/SettingsNavigation/components/SettingsItem/index.js: -------------------------------------------------------------------------------- 1 | export {SettingsItem as default} from "./SettingsItem.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/components/SettingsNavigation/components/SettingsItem/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .settings-item 4 | box-sizing: border-box 5 | width: 90% 6 | margin-top: 1rem 7 | padding: 0.75rem 1.5rem 8 | display: flex 9 | gap: 0.8rem 10 | border-radius: 1rem 11 | border: 1px solid transparent 12 | cursor: pointer 13 | 14 | svg 15 | width: 2rem 16 | height: 2rem 17 | 18 | h2 19 | margin: 0 20 | 21 | &:hover 22 | border: 1px solid colors.$gray 23 | .settings-item-active 24 | background-color: colors.$dark-gray 25 | border: 1px solid colors.$gray -------------------------------------------------------------------------------- /client/src/pages/Settings/components/SettingsNavigation/index.js: -------------------------------------------------------------------------------- 1 | export {SettingsNavigation as default} from "./SettingsNavigation.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/components/SettingsNavigation/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .settings-navigation 4 | background-color: colors.$lighter-background 5 | height: 100% 6 | display: flex 7 | flex-direction: column 8 | border-right: 2px solid colors.$dark-gray 9 | user-select: none 10 | 11 | p 12 | margin: 0.5rem 0 0 13 | padding: 0.5rem 1rem 14 | font-size: 1rem 15 | font-weight: 600 16 | color: colors.$subtext 17 | 18 | .settings-group 19 | display: flex 20 | flex-direction: column 21 | align-items: center -------------------------------------------------------------------------------- /client/src/pages/Settings/index.js: -------------------------------------------------------------------------------- 1 | export {Settings as default} from "./Settings.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Account/dialogs/PasswordChange/index.js: -------------------------------------------------------------------------------- 1 | export {PasswordChange as default} from "./PasswordChange.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Account/dialogs/PasswordChange/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .password-change 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | width: 20rem 8 | 9 | h2, p 10 | margin: 0 11 | 12 | p 13 | font-weight: 600 14 | font-size: 0.9rem 15 | 16 | .error 17 | border: 1px solid colors.$gray 18 | -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Account/dialogs/TwoFactorAuthentication/index.js: -------------------------------------------------------------------------------- 1 | export {TwoFactorAuthentication as default} from "./TwoFactorAuthentication.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Account/dialogs/TwoFactorAuthentication/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .two-factor-dialog 4 | display: flex 5 | align-items: center 6 | gap: 2rem 7 | user-select: none 8 | padding: 0.5rem 1rem 9 | 10 | .info-area 11 | display: flex 12 | flex-direction: column 13 | gap: 1rem 14 | width: 25rem 15 | padding: 1rem 0 16 | overflow-wrap: break-word 17 | 18 | h1, p 19 | margin: 0 20 | 21 | .totp-code 22 | user-select: text 23 | color: colors.$primary 24 | 25 | 26 | .setup-error 27 | border: 1px solid colors.$gray 28 | 29 | .action-row 30 | display: flex 31 | gap: 1rem 32 | 33 | .qr-code 34 | padding: 0.5rem 35 | border-radius: 0.5rem 36 | background-color: #fff -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Account/index.js: -------------------------------------------------------------------------------- 1 | export {Account as default} from "./Account.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Account/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .account-page 4 | display: flex 5 | flex-direction: column 6 | margin-top: 1rem 7 | gap: 2rem 8 | 9 | .account-section 10 | h2 11 | margin: 0 12 | 13 | p 14 | color: colors.$subtext 15 | 16 | .tfa-title 17 | display: flex 18 | align-items: center 19 | gap: 0.5rem 20 | 21 | p 22 | margin: 0 23 | padding: 0.2rem 0.5rem 24 | border-radius: 0.5rem 25 | 26 | .active 27 | background-color: colors.$success-opacity 28 | color: colors.$success 29 | 30 | .inactive 31 | background-color: colors.$error-opacity 32 | color: colors.$error 33 | 34 | .section-inner 35 | margin-top: 1rem 36 | display: flex 37 | justify-content: space-between 38 | align-items: center 39 | gap: 2rem 40 | 41 | p 42 | margin: 0 43 | 44 | .form-group 45 | display: flex 46 | width: 100% 47 | flex-direction: column 48 | gap: 0.3rem 49 | 50 | label 51 | color: colors.$subtext 52 | font-weight: 600 53 | 54 | .fd-updated 55 | border: 1px solid colors.$success -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Authentication/components/ProviderDialog/index.js: -------------------------------------------------------------------------------- 1 | export { ProviderDialog as default } from "./ProviderDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Authentication/components/ProviderDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" as * 2 | 3 | .provider-dialog 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | padding: 1rem 8 | min-width: 500px 9 | 10 | h2 11 | margin: 0 0 1rem 12 | 13 | .form-group 14 | display: flex 15 | flex-direction: column 16 | gap: 0.5rem 17 | 18 | label 19 | font-size: 0.9rem 20 | opacity: 0.7 21 | 22 | .advanced-settings 23 | display: flex 24 | flex-direction: column 25 | gap: 1rem 26 | margin-top: 1rem 27 | 28 | .advanced-form 29 | display: flex 30 | flex-direction: column 31 | gap: 1rem 32 | padding: 1rem 33 | background-color: $darker-gray 34 | border-radius: 8px -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Authentication/index.js: -------------------------------------------------------------------------------- 1 | export { Authentication as default } from "./Authentication.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Authentication/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" as * 2 | 3 | .authentication-page 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | 8 | .provider-title 9 | display: flex 10 | align-items: center 11 | gap: 1rem 12 | justify-content: space-between 13 | 14 | .provider-item 15 | display: flex 16 | align-items: center 17 | justify-content: space-between 18 | padding: 1rem 19 | background-color: $dark-gray 20 | border: 1px solid $gray 21 | border-radius: 8px 22 | gap: 1rem 23 | 24 | .left-area 25 | display: flex 26 | align-items: center 27 | gap: 1rem 28 | 29 | .provider-info 30 | display: flex 31 | flex-direction: column 32 | gap: 0.3rem 33 | 34 | h2 35 | margin: 0 36 | font-size: 1.2rem 37 | 38 | p 39 | margin: 0 40 | font-size: 0.9rem 41 | opacity: 0.7 42 | 43 | .provider-actions 44 | display: flex 45 | align-items: center 46 | gap: 1rem 47 | 48 | .menu 49 | cursor: pointer 50 | opacity: 0.7 51 | transition: opacity 0.2s 52 | width: 24px 53 | 54 | &:hover 55 | opacity: 1 56 | 57 | .delete-menu:hover 58 | color: $error 59 | 60 | .switch 61 | position: relative 62 | display: inline-block 63 | width: 40px 64 | height: 20px 65 | 66 | input 67 | opacity: 0 68 | width: 0 69 | height: 0 70 | 71 | &:checked + .slider 72 | background-color: $primary 73 | 74 | &:before 75 | transform: translateX(20px) 76 | 77 | .slider 78 | position: absolute 79 | cursor: pointer 80 | top: 0 81 | left: 0 82 | right: 0 83 | bottom: 0 84 | background-color: $subtext 85 | transition: .4s 86 | border-radius: 34px 87 | 88 | &:before 89 | position: absolute 90 | content: "" 91 | height: 16px 92 | width: 16px 93 | left: 2px 94 | bottom: 2px 95 | background-color: white 96 | transition: .4s 97 | border-radius: 50% -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/components/InviteMemberDialog/InviteMemberDialog.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { DialogProvider } from "@/common/components/Dialog"; 3 | import { useToast } from "@/common/contexts/ToastContext.jsx"; 4 | import IconInput from "@/common/components/IconInput"; 5 | import { mdiAccount } from "@mdi/js"; 6 | import Button from "@/common/components/Button"; 7 | import { postRequest } from "@/common/utils/RequestUtil.js"; 8 | import "./styles.sass"; 9 | 10 | export const InviteMemberDialog = ({ open, onClose, organization }) => { 11 | const { sendToast } = useToast(); 12 | const [username, setUsername] = useState(""); 13 | 14 | const handleInvite = async (e) => { 15 | e.preventDefault(); 16 | 17 | if (!username.trim()) { 18 | sendToast("Error", "Username is required"); 19 | return; 20 | } 21 | 22 | try { 23 | await postRequest(`organizations/${organization.id}/invite`, { 24 | username: username.trim(), 25 | }); 26 | 27 | sendToast("Success", "Invitation sent successfully"); 28 | onClose(); 29 | } catch (error) { 30 | sendToast("Error", error.message || "Failed to send invitation"); 31 | } 32 | }; 33 | 34 | useEffect(() => { 35 | if (open) { 36 | setUsername(""); 37 | } 38 | }, [open]); 39 | 40 | return ( 41 | 42 |
43 |

Invite Member

44 |

Invite a user to join {organization.name}

45 | 46 |
47 |
48 | 49 | 51 |
52 | 53 |
54 |
57 |
58 |
59 |
60 | ); 61 | }; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/components/InviteMemberDialog/index.js: -------------------------------------------------------------------------------- 1 | export { InviteMemberDialog as default } from "./InviteMemberDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/components/InviteMemberDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .invite-member-dialog 4 | h2 5 | margin: 0 0 0.2rem 6 | font-size: 1.5rem 7 | 8 | .subtitle 9 | margin: 0 0 1.5rem 10 | color: colors.$subtext 11 | font-size: 0.9rem 12 | 13 | .form-group 14 | margin-bottom: 1.25rem 15 | 16 | label 17 | display: block 18 | margin-bottom: 0.5rem 19 | font-size: 0.9rem 20 | color: colors.$subtext 21 | 22 | .dialog-actions 23 | display: flex 24 | justify-content: flex-end 25 | gap: 0.75rem 26 | margin-top: 2rem -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/components/MemberList/MemberList.jsx: -------------------------------------------------------------------------------- 1 | import Icon from "@mdi/react"; 2 | import { mdiAccount, mdiShieldAccount } from "@mdi/js"; 3 | import { deleteRequest } from "@/common/utils/RequestUtil.js"; 4 | import { useToast } from "@/common/contexts/ToastContext.jsx"; 5 | import "./styles.sass"; 6 | import Button from "@/common/components/Button/index.js"; 7 | 8 | export const MemberList = ({ members, organizationId, isOwner, refreshMembers }) => { 9 | const { sendToast } = useToast(); 10 | 11 | const handleRemoveMember = async (accountId) => { 12 | try { 13 | await deleteRequest(`organizations/${organizationId}/members/${accountId}`); 14 | sendToast("Success", "Member removed successfully"); 15 | refreshMembers(); 16 | } catch (error) { 17 | sendToast("Error", error.message || "Failed to remove member"); 18 | } 19 | }; 20 | 21 | return ( 22 |
23 | {members.map((member) => ( 24 |
25 |
26 | 28 |
29 |

{member.name}

30 |

{member.username}

31 |
32 | {member.role} 33 |
34 | {isOwner && member.role !== "owner" && ( 35 |
38 | ))} 39 |
40 | ); 41 | }; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/components/MemberList/index.js: -------------------------------------------------------------------------------- 1 | export { MemberList as default } from "./MemberList.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/components/MemberList/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .member-list 4 | display: flex 5 | flex-direction: column 6 | gap: 0.5rem 7 | margin-top: 0.5rem 8 | 9 | .member-item 10 | display: flex 11 | align-items: center 12 | justify-content: space-between 13 | background-color: colors.$dark-gray 14 | border-radius: 6px 15 | padding: 0.75rem 1rem 16 | transition: background-color 0.2s ease 17 | margin-bottom: 0.5rem 18 | 19 | &:hover 20 | background-color: colors.$gray 21 | 22 | .member-info 23 | display: flex 24 | align-items: center 25 | gap: 1rem 26 | 27 | svg 28 | width: 1.5rem 29 | height: 1.5rem 30 | color: colors.$primary 31 | 32 | .member-details 33 | h3 34 | margin: 0 35 | font-size: 1rem 36 | font-weight: 500 37 | 38 | p 39 | margin: 0.25rem 0 0 40 | color: colors.$subtext 41 | font-size: 0.9rem 42 | 43 | .member-role 44 | margin-left: 1rem 45 | font-size: 0.8rem 46 | color: colors.$primary 47 | background-color: colors.$primary-opacity 48 | padding: 0.25rem 0.5rem 49 | border-radius: 4px 50 | text-transform: capitalize -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/components/OrganizationDialog/index.js: -------------------------------------------------------------------------------- 1 | export { OrganizationDialog as default } from "./OrganizationDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/components/OrganizationDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .organization-dialog 4 | h2 5 | margin: 0 0 1.5rem 6 | font-size: 1.5rem 7 | 8 | .form-group 9 | margin-bottom: 1.25rem 10 | 11 | label 12 | display: block 13 | margin-bottom: 0.5rem 14 | font-size: 0.9rem 15 | color: colors.$subtext 16 | 17 | .dialog-actions 18 | display: flex 19 | justify-content: flex-end 20 | gap: 0.75rem 21 | margin-top: 2rem -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Organizations/index.js: -------------------------------------------------------------------------------- 1 | export { Organizations as default } from "./Organizations.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Sessions/index.js: -------------------------------------------------------------------------------- 1 | export {Sessions as default} from "./Sessions.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Sessions/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .sessions-page 4 | display: flex 5 | flex-direction: column 6 | margin-top: 1rem 7 | gap: 2rem 8 | 9 | 10 | .session 11 | border: 2px solid colors.$gray 12 | padding: 1rem 1.5rem 13 | border-radius: 1rem 14 | display: flex 15 | justify-content: space-between 16 | align-items: center 17 | 18 | .session-info 19 | display: flex 20 | gap: 1.5rem 21 | align-items: center 22 | 23 | .session-details 24 | display: flex 25 | flex-direction: column 26 | gap: 0.5rem 27 | 28 | h2, p 29 | margin: 0 30 | 31 | p 32 | font-weight: 600 33 | color: colors.$subtext 34 | 35 | .icon-container 36 | display: flex 37 | align-items: center 38 | justify-content: center 39 | padding: 0.7rem 40 | color: colors.$primary 41 | background-color: colors.$primary-opacity 42 | border-radius: 1rem 43 | 44 | svg 45 | width: 2rem 46 | height: 2rem 47 | 48 | .icon-current 49 | color: colors.$success 50 | background-color: colors.$success-opacity -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Users/components/ContextMenu/index.js: -------------------------------------------------------------------------------- 1 | export {ContextMenu as default} from "./ContextMenu.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Users/components/CreateUserDialog/index.js: -------------------------------------------------------------------------------- 1 | export {CreateUserDialog as default} from "./CreateUserDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Users/components/CreateUserDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .user-creation-dialog 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | 8 | .error 9 | display: flex 10 | justify-content: center 11 | align-items: center 12 | padding: 0.8rem 1rem 13 | background-color: colors.$error-opacity 14 | border: 1px solid colors.$gray 15 | color: colors.$error 16 | border-radius: 0.5rem 17 | font-size: 0.9rem 18 | margin: 0 19 | 20 | h2 21 | margin: 0 22 | 23 | .register-name-row 24 | display: flex 25 | width: 24rem 26 | gap: 1rem 27 | 28 | input 29 | width: 100% 30 | 31 | .form-group 32 | display: flex 33 | flex-direction: column 34 | gap: 0.3rem 35 | 36 | label 37 | color: colors.$subtext 38 | font-weight: 600 39 | 40 | .btn-area 41 | display: flex 42 | justify-content: flex-end -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Users/index.js: -------------------------------------------------------------------------------- 1 | export {Users as default} from "./Users.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Settings/pages/Users/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .users-page 4 | display: flex 5 | flex-direction: column 6 | gap: 1rem 7 | 8 | .user-title 9 | display: flex 10 | justify-content: space-between 11 | align-items: center 12 | 13 | .user-item 14 | display: grid 15 | grid-template-columns: 2fr 1fr 1fr 1fr 16 | align-items: center 17 | background-color: colors.$dark-gray 18 | border: 1px solid colors.$gray 19 | padding: 0.8rem 1rem 20 | border-radius: 1rem 21 | position: relative 22 | 23 | .user-name 24 | display: flex 25 | align-items: center 26 | gap: 0.5rem 27 | 28 | svg 29 | height: 2.5rem 30 | 31 | .totp 32 | display: flex 33 | align-items: center 34 | gap: 0.5rem 35 | color: colors.$error 36 | 37 | svg 38 | width: 2rem 39 | height: 2rem 40 | 41 | .totp-enabled 42 | color: colors.$success 43 | 44 | .menu 45 | width: 2.5rem 46 | height: 2.5rem 47 | cursor: pointer 48 | 49 | h2 50 | margin: 0 51 | font-weight: 600 52 | 53 | *:last-child 54 | grid-column: 5 / 5 -------------------------------------------------------------------------------- /client/src/pages/Settings/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .settings-page 4 | display: grid 5 | grid-template-columns: 18rem 1fr 6 | user-select: none 7 | 8 | .settings-content 9 | padding: 1rem 2rem 10 | 11 | hr 12 | background-color: colors.$gray 13 | width: 100% 14 | height: 3px 15 | border: none 16 | border-radius: 5px 17 | 18 | .settings-content-inner 19 | width: 80% 20 | overflow-x: hidden 21 | overflow-y: scroll 22 | 23 | .settings-header 24 | display: flex 25 | align-items: center 26 | padding-bottom: 0.5rem 27 | gap: 1rem 28 | 29 | svg 30 | width: 2.5rem 31 | height: 2.5rem 32 | 33 | h1 34 | margin: 0 35 | font-size: 24pt -------------------------------------------------------------------------------- /client/src/pages/Snippets/Snippets.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import { useState } from "react"; 3 | import { useSnippets } from "@/common/contexts/SnippetContext.jsx"; 4 | import SnippetsList from "@/pages/Snippets/components/SnippetsList"; 5 | import SnippetDialog from "@/pages/Snippets/components/SnippetDialog"; 6 | import Button from "@/common/components/Button"; 7 | import { mdiCodeBrackets, mdiPlus } from "@mdi/js"; 8 | import Icon from "@mdi/react"; 9 | 10 | export const Snippets = () => { 11 | const [dialogOpen, setDialogOpen] = useState(false); 12 | const [editSnippetId, setEditSnippetId] = useState(null); 13 | const { snippets } = useSnippets(); 14 | 15 | const openCreateDialog = () => { 16 | setEditSnippetId(null); 17 | setDialogOpen(true); 18 | }; 19 | 20 | const openEditDialog = (id) => { 21 | setEditSnippetId(id); 22 | setDialogOpen(true); 23 | }; 24 | 25 | const closeDialog = () => { 26 | setDialogOpen(false); 27 | setEditSnippetId(null); 28 | }; 29 | 30 | return ( 31 |
32 |
33 |
34 | 35 |
36 |

Snippets

37 |

Manage your command snippets for quick access in terminals

38 |
39 |
40 | 41 |
43 | 44 |
45 | 46 |
47 | 48 | 49 |
50 | ); 51 | }; -------------------------------------------------------------------------------- /client/src/pages/Snippets/components/SnippetDialog/index.js: -------------------------------------------------------------------------------- 1 | export { SnippetDialog as default } from "./SnippetDialog.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Snippets/components/SnippetDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .snippet-dialog 4 | .dialog-content 5 | margin-bottom: 1rem 6 | 7 | .snippet-dialog-title h2 8 | margin: 0 0 1rem 9 | 10 | .form-group 11 | margin-bottom: 1.25rem 12 | 13 | label 14 | display: block 15 | margin-bottom: 0.5rem 16 | color: colors.$light-gray 17 | font-size: 0.9rem 18 | 19 | .textarea-container 20 | position: relative 21 | width: 100% 22 | 23 | .custom-textarea 24 | width: 100% 25 | padding: 0.75rem 26 | border-radius: 4px 27 | border: 1px solid colors.$gray 28 | background-color: colors.$darker-gray 29 | color: colors.$white 30 | font-family: monospace 31 | font-size: 0.9rem 32 | resize: vertical 33 | box-sizing: border-box 34 | min-height: 120px 35 | 36 | &:focus 37 | border-color: colors.$primary 38 | outline: none 39 | 40 | .dialog-actions 41 | display: flex 42 | justify-content: flex-end 43 | gap: 0.75rem 44 | margin-top: 1rem 45 | padding-top: 1rem 46 | border-top: 1px solid colors.$gray -------------------------------------------------------------------------------- /client/src/pages/Snippets/components/SnippetsList/index.js: -------------------------------------------------------------------------------- 1 | export { SnippetsList as default } from "./SnippetsList.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Snippets/components/SnippetsList/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .snippets-list 4 | width: 100% 5 | 6 | .snippet-grid 7 | display: grid 8 | grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)) 9 | gap: 1rem 10 | 11 | .snippet-item 12 | background-color: colors.$dark-gray 13 | border-radius: 0.5rem 14 | padding: 1rem 15 | position: relative 16 | border: 1px solid colors.$gray 17 | 18 | .snippet-info 19 | h3 20 | margin: 0 21 | color: colors.$white 22 | font-size: 1.1rem 23 | 24 | p 25 | margin: 0.5rem 0 26 | color: colors.$light-gray 27 | font-size: 0.9rem 28 | 29 | .snippet-command 30 | margin: 0.8rem 0 0 31 | padding: 0.8rem 32 | background-color: colors.$darker-gray 33 | border-radius: 0.25rem 34 | color: colors.$white 35 | font-family: monospace 36 | font-size: 0.9rem 37 | white-space: pre-wrap 38 | word-break: break-all 39 | max-height: 150px 40 | overflow-y: auto 41 | 42 | .snippet-actions 43 | display: flex 44 | gap: 0.5rem 45 | position: absolute 46 | top: 0.75rem 47 | right: 0.75rem 48 | 49 | .action-button 50 | width: 32px 51 | height: 32px 52 | border-radius: 4px 53 | background-color: transparent 54 | border: none 55 | display: flex 56 | justify-content: center 57 | align-items: center 58 | cursor: pointer 59 | color: colors.$light-gray 60 | transition: background-color 0.2s ease 61 | 62 | svg 63 | width: 18px 64 | height: 18px 65 | 66 | &:hover 67 | background-color: colors.$gray 68 | 69 | &.delete:hover 70 | color: colors.$error 71 | 72 | .empty-snippets 73 | display: flex 74 | justify-content: center 75 | align-items: center 76 | height: 200px 77 | width: 100% 78 | 79 | p 80 | color: colors.$light-gray 81 | font-size: 1rem -------------------------------------------------------------------------------- /client/src/pages/Snippets/index.js: -------------------------------------------------------------------------------- 1 | export { Snippets as default } from "./Snippets.jsx"; -------------------------------------------------------------------------------- /client/src/pages/Snippets/styles.sass: -------------------------------------------------------------------------------- 1 | @use "@/common/styles/colors" 2 | 3 | .snippets-page 4 | padding: 2rem 5 | width: 100% 6 | height: 100% 7 | display: flex 8 | flex-direction: column 9 | box-sizing: border-box 10 | overflow-y: auto 11 | 12 | .snippets-header 13 | display: flex 14 | justify-content: space-between 15 | align-items: center 16 | margin-bottom: 2rem 17 | 18 | .snippets-title 19 | display: flex 20 | align-items: center 21 | gap: 1rem 22 | 23 | .header-left 24 | h1 25 | margin: 0 26 | color: colors.$white 27 | font-size: 1.8rem 28 | 29 | p 30 | margin: 0 31 | color: colors.$light-gray 32 | font-size: 0.95rem 33 | 34 | .snippets-content 35 | flex: 1 36 | overflow-y: auto -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import * as path from "path"; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | css: { 8 | preprocessorOptions: { 9 | sass: { 10 | api: "modern" 11 | } 12 | } 13 | }, 14 | resolve: { 15 | alias: { 16 | "@": path.resolve(__dirname, "src"), 17 | } 18 | }, 19 | server: { 20 | proxy: { 21 | "/api": "http://localhost:6989", 22 | } 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /docker-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | guacd -b 0.0.0.0 -l 4822 -f > /dev/null 2>&1 & 4 | exec node server/index.js 5 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | 3 | export default defineConfig({ 4 | title: "Nexterm", 5 | description: "The open source server management software for SSH, VNC & RDP", 6 | lastUpdated: true, 7 | cleanUrls: true, 8 | metaChunk: true, 9 | head: [ 10 | ["link", { rel: "icon", type: "image/png", href: "/logo.png" }], 11 | ["meta", { name: "theme-color", content: "#1C2232" }], 12 | ["meta", { property: "og:type", content: "website" }], 13 | ["meta", { property: "og:locale", content: "en" }], 14 | ["meta", { 15 | property: "og:title", 16 | content: "Nexterm | The open source server management software for SSH, VNC & RDP", 17 | }], 18 | ["meta", { property: "og:site_name", content: "Nexterm" }], 19 | ["meta", { property: "og:image", content: "/thumbnail.png" }], 20 | ["meta", { property: "og:image:type", content: "image/png" }], 21 | ["meta", { property: "twitter:card", content: "summary_large_image" }], 22 | ["meta", { property: "twitter:image:src", content: "/thumbnail.png" }], 23 | ["meta", { property: "og:url", content: "https://docs.nexterm.dev" }], 24 | ], 25 | themeConfig: { 26 | 27 | logo: "/logo.png", 28 | 29 | nav: [ 30 | { text: "Home", link: "/" }, 31 | { text: "Preview", link: "/preview" }, 32 | ], 33 | 34 | footer: { 35 | message: "Distributed under the MIT License", 36 | copyright: "© 2024 Mathias Wagner", 37 | }, 38 | search: { 39 | provider: "local", 40 | }, 41 | 42 | sidebar: [ 43 | { 44 | text: "Documentation", 45 | items: [ 46 | { text: "Home", link: "/" }, 47 | { text: "Preview", link: "/preview" }, 48 | { text: "Contributing", link: "/contributing" }, 49 | ], 50 | }, 51 | ], 52 | 53 | socialLinks: [ 54 | { icon: "github", link: "https://github.com/gnmyt/Nexterm" }, 55 | { icon: "discord", link: "https://dc.gnmyt.dev" }, 56 | ], 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /docs/assets/images/migration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/docs/assets/images/migration.png -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Nexterm 2 | 3 | You plan on contributing to Nexterm? That's great! This document will guide you through the process of contributing to 4 | the project. 5 | 6 | ## 📦 Prerequisites 7 | 8 | - [Node.js](https://nodejs.org/en/download/) (v18 or higher) 9 | - [Yarn](https://yarnpkg.com/getting-started/install) 10 | - [Git](https://git-scm.com/downloads) 11 | 12 | ## 🛠️ Installation 13 | 14 | 1. Clone the repository: 15 | ```sh 16 | git clone https://github.com/gnmyt/Nexterm.git 17 | ``` 18 | 2. Install the dependencies for the server: 19 | ```sh 20 | yarn install 21 | ``` 22 | 3. Install the dependencies for the client: 23 | ```sh 24 | cd client 25 | yarn install 26 | ``` 27 | 28 | ## 🏃 Running the development server 29 | 30 | Starting the development server is as simple as running the following command: 31 | 32 | ```sh 33 | yarn dev 34 | ``` 35 | 36 | This will start the server and the client in development mode. You can access the development server 37 | at [http://127.0.0.1:5173](http://127.0.0.1:5173). 38 | 39 | ## 🤝 Contributing 40 | 41 | 1. **Fork the repository**: Click the "Fork" button at the top right of 42 | the [repository page](https://github.com/gnmyt/Nexterm). 43 | 2. **Create a new branch**: 44 | ```sh 45 | git checkout -b feature/my-new-feature 46 | ``` 47 | 3. **Make your changes**: Implement your feature, fix, or improvement. 48 | 4. **Commit your changes**: 49 | ```sh 50 | git commit -m "Add feature: my new feature" 51 | ``` 52 | 5. **Push to your fork**: 53 | ```sh 54 | git push origin feature/my-new-feature 55 | ``` 56 | 6. **Open a pull request**: Go to the original repository and create a PR with a clear description. 57 | 58 | ## 📝 Guidelines 59 | 60 | - Follow the existing code style. 61 | - Keep PRs focused and minimal. 62 | - Include meaningful commit messages. 63 | - Link related issues when applicable. -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: Nexterm 6 | text: Server management 7 | tagline: The open source server management software for SSH, VNC & RDP 8 | actions: 9 | - theme: brand 10 | text: Run preview 11 | link: /preview 12 | - theme: alt 13 | text: GitHub 14 | link: https://github.com/gnmyt/Nexterm 15 | image: 16 | src: /logo.png 17 | alt: MySpeed 18 | 19 | features: 20 | - icon: 👀 21 | title: Open Preview 22 | details: Nexterm is currently in development and is open for preview. 23 | - icon: 🔒 24 | title: Secure 25 | details: Two-factor authentication, session management and encryption built-in. 26 | - icon: 📁 27 | title: Structured 28 | details: Nexterm is structured into folders and tabs for easy navigation. 29 | - icon: 🏢 30 | title: Organizations 31 | details: Seamlessly share server access with your team members. 32 | - icon: ✂️ 33 | title: Snippets 34 | details: Create and manage snippets for quick access to commands. 35 | - icon: 📦 36 | title: Apps 37 | details: Automate app installations and configurations with apps. 38 | 39 | --- 40 | 41 | -------------------------------------------------------------------------------- /docs/powertools-migration.md: -------------------------------------------------------------------------------- 1 |

PowerTools migrated to Nexterm

2 | 3 | ![Nexterm](./assets/images/migration.png) 4 | 5 | ## Introduction 6 | 7 | You probably got redirected here from the [PowerTools](https://tools.gnmyt.dev) website. In this article, I want to 8 | explain why I decided to make this change and what it means for you. 9 | 10 | ## What was PowerTools? 11 | 12 | If you ended up here and don't know what PowerTools is, you might have looked through the GitHub repository and found 13 | this page. PowerTools was a collection of tools I created to help developers in their daily work. 14 | 15 | It had tools like Base64 Encoder/Decoder, QR-Code generators, and some other tools that I found useful. The main part 16 | however was the App Store, where you could deploy software to your server with a single click. 17 | 18 | ## Why the change? 19 | 20 | For starters, hosting the server for the SSH connections was a pain. Not only were the servers down most of the time, 21 | but there was also the issue of security. Every time a connection to a server has been made, every connection can be 22 | traced back to the PowerTools server. If anyone tried to abuse this by hacking others or ddos someone, it would be 23 | traced back to my server, and I would be responsible for it. This was a huge security risk, and I didn't want to take 24 | it. 25 | 26 | Another reason was the fact that PowerTools used bash install scripts to deploy software. Not only was this a pain to 27 | make those scripts available for most linux distributions, but it was also a pain to maintain them. Because of that, 28 | updating the software was not even possible most of the time. 29 | 30 | ## What now? 31 | 32 | I decided to make a change. I liked the idea of the app store in PowerTools, but I wanted to make it better. This is why 33 | I implemented the app store in Nexterm. Since you already imported your servers into Nexterm, you can now deploy 34 | software 35 | to your servers with a single click. The difference is that the software is now deployed using Docker, which is more 36 | secure and easier to maintain. 37 | 38 | ## What's with the other tools? 39 | 40 | Since Nexterm is a server management software, it doesn't really make sense to have tools like Base64 Encoder/Decoder 41 | in it. However, I would recommend you to check out the [IT Tools](https://it-tools.tech/) project, which is an 42 | open-source collection of even more tools than PowerTools had. -------------------------------------------------------------------------------- /docs/preview.md: -------------------------------------------------------------------------------- 1 | # 🚀 Run preview 2 | 3 | > [!CAUTION] 4 | > Nexterm is currently in early development and subject to change. It is not recommended to use it in a production 5 | > environment. 6 | 7 | ## 📦 Installation 8 | 9 | Since 1.0.3-OPEN-PREVIEW, you are required to set an encryption key. You can generate one with `openssl rand -hex 32`. 10 | 11 | ### 🐳 Docker 12 | 13 | ```shell 14 | docker run -d \ 15 | -e ENCRYPTION_KEY="aba3aa8e29b9904d5d8d705230b664c053415c54be20ad13be99af0057dfa23a" \ 16 | -p 6989:6989 \ 17 | --name nexterm \ 18 | --restart always \ 19 | -v nexterm:/app/data \ 20 | germannewsmaker/nexterm:1.0.3-OPEN-PREVIEW 21 | ``` 22 | 23 | ### 📦 Docker Compose 24 | 25 | ```yaml 26 | services: 27 | nexterm: 28 | environment: 29 | ENCRYPTION_KEY: "aba3aa8e29b9904d5d8d705230b664c053415c54be20ad13be99af0057dfa23a" # Replace with your generated key 30 | ports: 31 | - "6989:6989" 32 | restart: always 33 | volumes: 34 | - nexterm:/app/data 35 | image: germannewsmaker/nexterm:1.0.3-OPEN-PREVIEW 36 | volumes: 37 | nexterm: 38 | ``` 39 | 40 | ```sh 41 | docker-compose up -d 42 | ``` -------------------------------------------------------------------------------- /docs/public/CNAME: -------------------------------------------------------------------------------- 1 | docs.nexterm.dev -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/docs/public/logo.png -------------------------------------------------------------------------------- /docs/public/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/Nexterm/2309a0b963d21995f155a682bef7c6a5c267d00b/docs/public/thumbnail.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nexterm", 3 | "version": "1.0.3-OPEN-PREVIEW", 4 | "main": "server/index.js", 5 | "repository": "https://github.com/gnmyt/Nexterm.git", 6 | "author": "Mathias Wagner", 7 | "license": "MIT", 8 | "dependencies": { 9 | "axios": "^1.9.0", 10 | "bcrypt": "^6.0.0", 11 | "decompress": "^4.2.1", 12 | "express": "^5.1.0", 13 | "express-ws": "^5.0.2", 14 | "joi": "^17.13.3", 15 | "js-yaml": "^4.1.0", 16 | "openid-client": "^6.5.0", 17 | "sequelize": "^6.37.7", 18 | "speakeasy": "^2.0.0", 19 | "sqlite3": "^5.1.7", 20 | "ssh2": "^1.16.0", 21 | "ws": "^8.18.2" 22 | }, 23 | "devDependencies": { 24 | "concurrently": "^9.1.2", 25 | "dotenv": "^16.5.0", 26 | "nodemon": "^3.1.10", 27 | "vitepress": "^1.6.3" 28 | }, 29 | "scripts": { 30 | "dev:server": "nodemon server/index.js", 31 | "dev:client": "cd client && yarn run dev", 32 | "dev": "concurrently --kill-others-on-fail \"yarn dev:server\" \"yarn dev:client\"", 33 | "docs:dev": "vitepress dev docs", 34 | "docs:build": "vitepress build docs", 35 | "docs:preview": "vitepress preview docs" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/controllers/auth.js: -------------------------------------------------------------------------------- 1 | const Account = require("../models/Account"); 2 | const Session = require("../models/Session"); 3 | const speakeasy = require("speakeasy"); 4 | const { compare } = require("bcrypt"); 5 | 6 | module.exports.login = async (configuration, user) => { 7 | const account = await Account.findOne({ where: { username: configuration.username } }); 8 | 9 | // Check if account exists 10 | if (account === null) 11 | return { code: 201, message: "Username or password incorrect" }; 12 | 13 | // Check if password is correct 14 | if (!(await compare(configuration.password, account.password))) 15 | return { code: 201, message: "Username or password incorrect" }; 16 | 17 | // Check if TOTP is required 18 | if (account.totpEnabled && !configuration.code) 19 | return { code: 202, message: "TOTP is required for this account" }; 20 | 21 | // Check if TOTP is correct 22 | if (account.totpEnabled) { 23 | const tokenCorrect = speakeasy.totp.verify({ 24 | secret: account.totpSecret || "", 25 | encoding: "base32", 26 | token: configuration.code, 27 | }); 28 | 29 | if (!tokenCorrect) 30 | return { code: 203, message: "Your provided code is invalid or has expired." }; 31 | } 32 | 33 | // Create Session 34 | const session = await Session.create({ 35 | accountId: account.id, 36 | ip: user.ip, 37 | userAgent: user.userAgent, 38 | }); 39 | 40 | return { token: session.token, totpRequired: account.totpEnabled }; 41 | }; 42 | 43 | module.exports.logout = async token => { 44 | const session = await Session.findOne({ where: { token } }); 45 | 46 | if (session === null) 47 | return { code: 204, message: "Your session token is invalid" }; 48 | 49 | await Session.destroy({ where: { token } }); 50 | }; 51 | -------------------------------------------------------------------------------- /server/controllers/guacamoleProxy.js: -------------------------------------------------------------------------------- 1 | const ClientConnection = require('../lib/ClientConnection.js'); 2 | 3 | module.exports = async (ws, settings) => { 4 | try { 5 | this.clientOptions = { 6 | maxInactivityTime: 10000, 7 | 8 | connectionDefaultSettings: { 9 | rdp: { 'args': 'connect', 'port': '3389', 'width': 1024, 'height': 768, 'dpi': 96, }, 10 | vnc: { 'args': 'connect', 'port': '5900', 'width': 1024, 'height': 768, 'dpi': 96, }, 11 | } 12 | }; 13 | 14 | new ClientConnection(ws, this.clientOptions, settings); 15 | } catch (error) { 16 | console.error("Error in guacamoleProxy:", error); 17 | ws.close(1011, "Internal server error"); 18 | } 19 | } -------------------------------------------------------------------------------- /server/controllers/session.js: -------------------------------------------------------------------------------- 1 | const Session = require("../models/Session"); 2 | const Account = require("../models/Account"); 3 | 4 | module.exports.listSessions = async (accountId, currentSessionId) => { 5 | return (await Session.findAll({ where: { accountId } })) 6 | .sort((a, b) => b.lastActivity - a.lastActivity) 7 | .map(session => ({ id: session.id, ip: session.ip, userAgent: session.userAgent, lastActivity: session.lastActivity, 8 | current: session.id === currentSessionId })); 9 | } 10 | 11 | module.exports.createSession = async (accountId, userAgent) => { 12 | const account = await Account.findByPk(accountId); 13 | 14 | if (account === null) 15 | return { code: 102, message: "The provided account does not exist" }; 16 | 17 | const session = await Session.create({ accountId, ip: "Admin", userAgent }); 18 | 19 | return { token: session.token }; 20 | } 21 | 22 | module.exports.destroySession = async (accountId, sessionId) => { 23 | const session = await Session.findOne({ where: { accountId, id: sessionId } }); 24 | 25 | if (session === null) 26 | return { code: 206, message: "The provided session does not exist" }; 27 | 28 | await Session.destroy({ where: { accountId, id: sessionId } }); 29 | 30 | return { message: "The session has been successfully destroyed" }; 31 | }; -------------------------------------------------------------------------------- /server/controllers/snippet.js: -------------------------------------------------------------------------------- 1 | const Snippet = require("../models/Snippet"); 2 | 3 | module.exports.createSnippet = async (accountId, configuration) => { 4 | return await Snippet.create({ ...configuration, accountId }); 5 | }; 6 | 7 | module.exports.deleteSnippet = async (accountId, snippetId) => { 8 | const snippet = await Snippet.findOne({ where: { accountId: accountId, id: snippetId } }); 9 | 10 | if (snippet === null) { 11 | return { code: 501, message: "Snippet does not exist" }; 12 | } 13 | 14 | await Snippet.destroy({ where: { id: snippetId } }); 15 | }; 16 | 17 | module.exports.editSnippet = async (accountId, snippetId, configuration) => { 18 | const snippet = await Snippet.findOne({ where: { accountId: accountId, id: snippetId } }); 19 | 20 | if (snippet === null) { 21 | return { code: 501, message: "Snippet does not exist" }; 22 | } 23 | 24 | await Snippet.update(configuration, { where: { id: snippetId } }); 25 | }; 26 | 27 | module.exports.getSnippet = async (accountId, snippetId) => { 28 | const snippet = await Snippet.findOne({ where: { accountId: accountId, id: snippetId } }); 29 | 30 | if (snippet === null) { 31 | return { code: 501, message: "Snippet does not exist" }; 32 | } 33 | 34 | return snippet; 35 | }; 36 | 37 | module.exports.listSnippets = async (accountId) => { 38 | return await Snippet.findAll({ where: { accountId } }); 39 | }; -------------------------------------------------------------------------------- /server/middlewares/guacamole.js: -------------------------------------------------------------------------------- 1 | const { authorizeGuacamole } = require("./auth"); 2 | const guacamoleProxy = require("../controllers/guacamoleProxy"); 3 | 4 | module.exports = async (req, res) => { 5 | const settings = await authorizeGuacamole(req); 6 | if (!settings) { 7 | return res.status(403).json({ error: "Unauthorized" }); 8 | } 9 | 10 | guacamoleProxy(req.ws, settings); 11 | }; -------------------------------------------------------------------------------- /server/middlewares/permission.js: -------------------------------------------------------------------------------- 1 | module.exports.isAdmin = async (req, res, next) => { 2 | if (req.user.role !== "admin") { 3 | return res.status(403).json({ code: 403, message: "Forbidden" }); 4 | } 5 | 6 | next(); 7 | } -------------------------------------------------------------------------------- /server/middlewares/pve.js: -------------------------------------------------------------------------------- 1 | const Session = require("../models/Session"); 2 | const Account = require("../models/Account"); 3 | const PVEServer = require("../models/PVEServer"); 4 | const { validateServerAccess } = require("../controllers/server"); 5 | 6 | module.exports = async (ws, req) => { 7 | const authHeader = req.query["sessionToken"]; 8 | const serverId = req.query["serverId"]; 9 | let containerId = req.query["containerId"]; 10 | 11 | if (!authHeader) { 12 | ws.close(4001, "You need to provide the token in the 'sessionToken' parameter"); 13 | return; 14 | } 15 | 16 | if (!serverId) { 17 | ws.close(4002, "You need to provide the serverId in the 'serverId' parameter"); 18 | return; 19 | } 20 | 21 | if (!containerId) { 22 | containerId = "0"; 23 | } 24 | 25 | req.session = await Session.findOne({ where: { token: authHeader } }); 26 | if (req.session === null) { 27 | ws.close(4003, "The token is not valid"); 28 | return; 29 | } 30 | 31 | await Session.update({ lastActivity: new Date() }, { where: { id: req.session.id } }); 32 | 33 | req.user = await Account.findByPk(req.session.accountId); 34 | if (req.user === null) { 35 | ws.close(4004, "The token is not valid"); 36 | return; 37 | } 38 | 39 | const server = await PVEServer.findByPk(serverId); 40 | if (server === null) return; 41 | 42 | if (!((await validateServerAccess(req.user.id, server)).valid)) { 43 | ws.close(4005, "You don't have access to this server"); 44 | return; 45 | } 46 | 47 | console.log("Authorized connection to pve server " + server.ip + " with container " + containerId); 48 | 49 | return { server, containerId }; 50 | } -------------------------------------------------------------------------------- /server/models/Account.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | const speakeasy = require("speakeasy"); 4 | 5 | module.exports = db.define("accounts", { 6 | firstName: { 7 | type: Sequelize.STRING, 8 | allowNull: false, 9 | }, 10 | lastName: { 11 | type: Sequelize.STRING, 12 | allowNull: false, 13 | }, 14 | username: { 15 | type: Sequelize.STRING, 16 | allowNull: false, 17 | }, 18 | password: { 19 | type: Sequelize.STRING, 20 | allowNull: false, 21 | }, 22 | totpEnabled: { 23 | type: Sequelize.BOOLEAN, 24 | defaultValue: false, 25 | }, 26 | role: { 27 | type: Sequelize.STRING, 28 | defaultValue: "user", 29 | }, 30 | totpSecret: { 31 | type: Sequelize.STRING, 32 | defaultValue: () => { 33 | return speakeasy.generateSecret({ name: "Nexterm" }).base32; 34 | }, 35 | }, 36 | }, { freezeTableName: true, createdAt: false, updatedAt: false }); -------------------------------------------------------------------------------- /server/models/AppSource.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | 4 | module.exports = db.define("app_sources", { 5 | name: { 6 | type: Sequelize.STRING, 7 | allowNull: false, 8 | primaryKey: true, 9 | }, 10 | url: { 11 | type: Sequelize.STRING, 12 | allowNull: false, 13 | }, 14 | }, { freezeTableName: true, createdAt: false, updatedAt: false }); -------------------------------------------------------------------------------- /server/models/Folder.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | 4 | module.exports = db.define("folders", { 5 | name: { 6 | type: Sequelize.STRING, 7 | allowNull: false, 8 | }, 9 | accountId: { 10 | type: Sequelize.INTEGER, 11 | allowNull: true, 12 | }, 13 | organizationId: { 14 | type: Sequelize.INTEGER, 15 | allowNull: true, 16 | }, 17 | parentId: { 18 | type: Sequelize.INTEGER, 19 | allowNull: true, 20 | }, 21 | position: { 22 | type: Sequelize.INTEGER, 23 | defaultValue: 0, 24 | allowNull: false, 25 | } 26 | }, { freezeTableName: true, createdAt: false, updatedAt: false }); -------------------------------------------------------------------------------- /server/models/Organization.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | 4 | module.exports = db.define("organizations", { 5 | name: { 6 | type: Sequelize.STRING, 7 | allowNull: false, 8 | }, 9 | description: { 10 | type: Sequelize.STRING, 11 | allowNull: true, 12 | }, 13 | ownerId: { 14 | type: Sequelize.INTEGER, 15 | allowNull: false 16 | } 17 | }, { freezeTableName: true }); -------------------------------------------------------------------------------- /server/models/OrganizationMember.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | 4 | module.exports = db.define("organization_members", { 5 | organizationId: { 6 | type: Sequelize.INTEGER, 7 | primaryKey: true, 8 | allowNull: false, 9 | }, 10 | accountId: { 11 | type: Sequelize.INTEGER, 12 | allowNull: false, 13 | }, 14 | role: { 15 | type: Sequelize.ENUM("owner", "member"), 16 | defaultValue: "member", 17 | allowNull: false, 18 | }, 19 | status: { 20 | type: Sequelize.ENUM("pending", "active"), 21 | defaultValue: "pending", 22 | allowNull: false, 23 | }, 24 | invitedBy: { 25 | type: Sequelize.INTEGER, 26 | allowNull: false, 27 | }, 28 | }, { freezeTableName: true }); -------------------------------------------------------------------------------- /server/models/PVEServer.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | 4 | module.exports = db.define("pve_servers", { 5 | name: { 6 | type: Sequelize.STRING, 7 | allowNull: false, 8 | }, 9 | accountId: { 10 | type: Sequelize.INTEGER, 11 | allowNull: true, 12 | }, 13 | organizationId: { 14 | type: Sequelize.INTEGER, 15 | allowNull: true, 16 | }, 17 | folderId: { 18 | type: Sequelize.INTEGER, 19 | allowNull: false, 20 | }, 21 | ip: { 22 | type: Sequelize.STRING, 23 | allowNull: false, 24 | }, 25 | port: { 26 | type: Sequelize.INTEGER, 27 | allowNull: false, 28 | }, 29 | username: { 30 | type: Sequelize.STRING, 31 | allowNull: false, 32 | }, 33 | password: { 34 | type: Sequelize.STRING, 35 | allowNull: false, 36 | }, 37 | online: { 38 | type: Sequelize.BOOLEAN, 39 | defaultValue: false 40 | }, 41 | resources: { 42 | type: Sequelize.JSON, 43 | defaultValue: [], 44 | }, 45 | }, { freezeTableName: true, createdAt: false, updatedAt: false }); -------------------------------------------------------------------------------- /server/models/Server.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | 4 | module.exports = db.define("servers", { 5 | name: { 6 | type: Sequelize.STRING, 7 | allowNull: false, 8 | }, 9 | accountId: { 10 | type: Sequelize.INTEGER, 11 | allowNull: true, 12 | }, 13 | organizationId: { 14 | type: Sequelize.INTEGER, 15 | allowNull: true, 16 | }, 17 | position: { 18 | type: Sequelize.INTEGER, 19 | defaultValue: 0, 20 | allowNull: false, 21 | }, 22 | folderId: { 23 | type: Sequelize.INTEGER, 24 | allowNull: false, 25 | }, 26 | icon: { 27 | type: Sequelize.STRING, 28 | allowNull: true, 29 | }, 30 | protocol: { 31 | type: Sequelize.STRING, 32 | allowNull: false, 33 | }, 34 | ip: { 35 | type: Sequelize.STRING, 36 | allowNull: false, 37 | }, 38 | port: { 39 | type: Sequelize.INTEGER, 40 | allowNull: false, 41 | }, 42 | identities: { 43 | type: Sequelize.JSON, 44 | defaultValue: [], 45 | }, 46 | config: { 47 | type: Sequelize.JSON, 48 | allowNull: true, 49 | }, 50 | }, { freezeTableName: true, createdAt: false, updatedAt: false }); -------------------------------------------------------------------------------- /server/models/Session.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | const crypto = require("crypto"); 4 | 5 | module.exports = db.define("sessions", { 6 | accountId: { 7 | type: Sequelize.INTEGER, 8 | allowNull: false, 9 | }, 10 | token: { 11 | type: Sequelize.STRING, 12 | defaultValue: () => crypto.randomBytes(48).toString("hex"), 13 | }, 14 | ip: { 15 | type: Sequelize.STRING, 16 | allowNull: false, 17 | }, 18 | userAgent: { 19 | type: Sequelize.STRING, 20 | allowNull: false, 21 | }, 22 | lastActivity: { 23 | type: Sequelize.DATE, 24 | defaultValue: Sequelize.NOW, 25 | }, 26 | }, { freezeTableName: true, createdAt: false, updatedAt: false }); -------------------------------------------------------------------------------- /server/models/Snippet.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const db = require("../utils/database"); 3 | 4 | module.exports = db.define("snippets", { 5 | name: { 6 | type: Sequelize.STRING, 7 | allowNull: false, 8 | }, 9 | accountId: { 10 | type: Sequelize.INTEGER, 11 | allowNull: false, 12 | }, 13 | command: { 14 | type: Sequelize.TEXT, 15 | allowNull: false, 16 | }, 17 | description: { 18 | type: Sequelize.TEXT, 19 | allowNull: true, 20 | } 21 | }, { freezeTableName: true, createdAt: false, updatedAt: false }); -------------------------------------------------------------------------------- /server/routes/apps.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const { 3 | getAppsByCategory, 4 | getApps, 5 | searchApp, 6 | getAppSources, 7 | createAppSource, 8 | deleteAppSource, 9 | updateAppUrl, 10 | refreshAppSources, 11 | } = require("../controllers/appSource"); 12 | const { validateSchema } = require("../utils/schema"); 13 | const { createAppSourceValidation, updateAppUrlValidation } = require("../validations/appSource"); 14 | 15 | const app = Router(); 16 | 17 | app.post("/refresh", async (req, res) => { 18 | await refreshAppSources(); 19 | res.json({ message: "Apps got successfully refreshed" }); 20 | }); 21 | 22 | app.get("/", async (req, res) => { 23 | if (req.query.category) { 24 | res.json(await getAppsByCategory(req.query.category)); 25 | } else if (req.query.search) { 26 | res.json(await searchApp(req.query.search)); 27 | } else { 28 | res.json(await getApps()); 29 | } 30 | }); 31 | 32 | app.get("/sources", async (req, res) => { 33 | res.json(await getAppSources()); 34 | }); 35 | 36 | app.put("/sources", async (req, res) => { 37 | if (validateSchema(res, createAppSourceValidation, req.body)) return; 38 | 39 | const appSource = await createAppSource(req.body); 40 | if (appSource?.code) return res.json(appSource); 41 | 42 | res.json({ message: "App source got successfully created" }); 43 | }); 44 | 45 | app.delete("/sources/:appSource", async (req, res) => { 46 | if (req.params.appSource === "official") 47 | return res.json({ code: 103, message: "You can't delete the default app source" }); 48 | 49 | const appSource = await deleteAppSource(req.params.appSource); 50 | if (appSource?.code) return res.json(appSource); 51 | 52 | res.json({ message: "App source got successfully deleted" }); 53 | }); 54 | 55 | app.patch("/sources/:appSource", async (req, res) => { 56 | if (req.params.appSource === "official") 57 | return res.json({ code: 103, message: "You can't edit the default app source" }); 58 | 59 | if (validateSchema(res, updateAppUrlValidation, req.body)) return; 60 | 61 | const appSource = await updateAppUrl(req.params.appSource, req.body.url); 62 | if (appSource?.code) return res.json(appSource); 63 | 64 | res.json({ message: "App source got successfully edited" }); 65 | }); 66 | 67 | module.exports = app; 68 | -------------------------------------------------------------------------------- /server/routes/auth.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const { login, logout } = require("../controllers/auth"); 3 | const { loginValidation, tokenValidation } = require("../validations/auth"); 4 | const { validateSchema } = require("../utils/schema"); 5 | 6 | const app = Router(); 7 | 8 | app.post("/login", async (req, res) => { 9 | if (validateSchema(res, loginValidation, req.body)) return; 10 | 11 | const session = await login(req.body, { 12 | ip: req.ip, 13 | userAgent: req.header("User-Agent") || "None", 14 | }); 15 | if (session?.code) return res.json(session); 16 | 17 | res 18 | .header("Authorization", session?.token) 19 | .json({ ...session, message: "Your session got successfully created" }); 20 | }); 21 | 22 | app.post("/logout", async (req, res) => { 23 | if (validateSchema(res, tokenValidation, req.body)) return; 24 | 25 | const session = await logout(req.body.token); 26 | if (session) return res.json(session); 27 | 28 | res.json({ message: "Your session got deleted successfully" }); 29 | }); 30 | 31 | module.exports = app; 32 | -------------------------------------------------------------------------------- /server/routes/folder.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const { folderCreationValidation, folderEditValidation } = require("../validations/folder"); 3 | const { createFolder, deleteFolder, listFolders, editFolder } = require("../controllers/folder"); 4 | const { validateSchema } = require("../utils/schema"); 5 | 6 | const app = Router(); 7 | 8 | app.get("/list", async (req, res) => { 9 | res.json(await listFolders(req.user.id)); 10 | }); 11 | 12 | app.put("/", async (req, res) => { 13 | if (validateSchema(res, folderCreationValidation, req.body)) return; 14 | 15 | const folder = await createFolder(req.user.id, req.body); 16 | if (folder?.code) return res.json(folder); 17 | 18 | res.json({ message: "Folder has been successfully created", id: folder.id }); 19 | }); 20 | 21 | app.patch("/:folderId", async (req, res) => { 22 | if (validateSchema(res, folderEditValidation, req.body)) return; 23 | 24 | const response = await editFolder(req.user.id, req.params.folderId, req.body); 25 | if (response) return res.json(response); 26 | 27 | res.json({ message: "Folder has been successfully edited" }); 28 | }); 29 | 30 | app.delete("/:folderId", async (req, res) => { 31 | const response = await deleteFolder(req.user.id, req.params.folderId); 32 | if (response) return res.json(response); 33 | 34 | res.json({ message: "Folder has been successfully deleted" }); 35 | }); 36 | 37 | 38 | module.exports = app; 39 | -------------------------------------------------------------------------------- /server/routes/identity.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const { validateSchema } = require("../utils/schema"); 3 | const { listIdentities, createIdentity, deleteIdentity, updateIdentity } = require("../controllers/identity"); 4 | const { createIdentityValidation, updateIdentityValidation } = require("../validations/identity"); 5 | 6 | const app = Router(); 7 | 8 | app.get("/list", async (req, res) => { 9 | res.json(await listIdentities(req.user.id)); 10 | }); 11 | 12 | app.put("/", async (req, res) => { 13 | if (validateSchema(res, createIdentityValidation, req.body)) return; 14 | 15 | const identity = await createIdentity(req.user.id, req.body); 16 | if (identity?.code) return res.json(identity); 17 | 18 | res.json({ message: "Identity got successfully created", id: identity.id }); 19 | }); 20 | 21 | app.delete("/:identityId", async (req, res) => { 22 | const identity = await deleteIdentity(req.user.id, req.params.identityId); 23 | if (identity?.code) return res.json(identity); 24 | 25 | res.json({ message: "Identity got successfully deleted" }); 26 | }); 27 | 28 | app.patch("/:identityId", async (req, res) => { 29 | if (validateSchema(res, updateIdentityValidation, req.body)) return; 30 | 31 | const identity = await updateIdentity(req.user.id, req.params.identityId, req.body); 32 | if (identity?.code) return res.json(identity); 33 | 34 | res.json({ message: "Identity got successfully edited" }); 35 | }); 36 | 37 | module.exports = app; -------------------------------------------------------------------------------- /server/routes/pveQEMU.js: -------------------------------------------------------------------------------- 1 | const { getPrimaryNode, createTicket, openVNCConsole } = require("../controllers/pve"); 2 | const guacamoleProxy = require("../controllers/guacamoleProxy"); 3 | const preparePVE = require("../middlewares/pve"); 4 | const { createVNCToken } = require("../utils/tokenGenerator"); 5 | 6 | module.exports = async (ws, req) => { 7 | const pveObj = await preparePVE(ws, req); 8 | if (!pveObj) return; 9 | 10 | const { server, containerId } = pveObj; 11 | 12 | try { 13 | const ticket = await createTicket({ ip: server.ip, port: server.port }, server.username, server.password); 14 | const node = await getPrimaryNode({ ip: server.ip, port: server.port }, ticket); 15 | const vncTicket = await openVNCConsole({ ip: server.ip, port: server.port }, node.node, containerId, ticket); 16 | 17 | guacamoleProxy(ws, createVNCToken(server.ip, vncTicket.port, undefined, vncTicket.ticket)); 18 | } catch (error) { 19 | ws.close(4005, error.message); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /server/routes/server.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const { createServer, deleteServer, editServer, getServer, listServers, duplicateServer } = require("../controllers/server"); 3 | const { createServerValidation, updateServerValidation } = require("../validations/server"); 4 | const { validateSchema } = require("../utils/schema"); 5 | 6 | const app = Router(); 7 | 8 | app.get("/list", async (req, res) => { 9 | res.json(await listServers(req.user.id)); 10 | }); 11 | 12 | app.get("/:serverId", async (req, res) => { 13 | const server = await getServer(req.user.id, req.params.serverId); 14 | if (server?.code) return res.json(server); 15 | 16 | res.json(server); 17 | }); 18 | 19 | app.put("/", async (req, res) => { 20 | if (validateSchema(res, createServerValidation, req.body)) return; 21 | 22 | const server = await createServer(req.user.id, req.body); 23 | if (server?.code) return res.json(server); 24 | 25 | res.json({ message: "Server got successfully created", id: server.id }); 26 | }); 27 | 28 | app.delete("/:serverId", async (req, res) => { 29 | const server = await deleteServer(req.user.id, req.params.serverId); 30 | if (server?.code) return res.json(server); 31 | 32 | res.json({ message: "Server got successfully deleted" }); 33 | }); 34 | 35 | app.patch("/:serverId", async (req, res) => { 36 | if (validateSchema(res, updateServerValidation, req.body)) return; 37 | 38 | const server = await editServer(req.user.id, req.params.serverId, req.body); 39 | if (server?.code) return res.json(server); 40 | 41 | res.json({ message: "Server got successfully edited" }); 42 | }); 43 | 44 | app.post("/:serverId/duplicate", async (req, res) => { 45 | const server = await duplicateServer(req.user.id, req.params.serverId); 46 | if (server?.code) return res.json(server); 47 | 48 | res.json({ message: "Server got successfully duplicated" }); 49 | }); 50 | 51 | module.exports = app; -------------------------------------------------------------------------------- /server/routes/service.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const { getFTSStatus } = require("../controllers/account"); 3 | 4 | const app = express.Router(); 5 | 6 | // Checks if the server is not setup yet (First Time Setup) 7 | app.get("/is-fts", (req, res) => { 8 | getFTSStatus() 9 | .then(status => res.json(status)) 10 | .catch(err => res.status(500).json({ error: err.message })); 11 | }); 12 | 13 | module.exports = app; -------------------------------------------------------------------------------- /server/routes/session.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const { listSessions, destroySession } = require("../controllers/session"); 3 | 4 | const app = Router(); 5 | 6 | app.get("/list", async (req, res) => { 7 | res.json(await listSessions(req.user.id, req.session.id)); 8 | }); 9 | 10 | app.delete("/:id", async (req, res) => { 11 | res.json(await destroySession(req.user.id, req.params.id)); 12 | }); 13 | 14 | module.exports = app; -------------------------------------------------------------------------------- /server/routes/snippet.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const { validateSchema } = require("../utils/schema"); 3 | const { createSnippet, deleteSnippet, editSnippet, getSnippet, listSnippets } = require("../controllers/snippet"); 4 | const { snippetCreationValidation, snippetEditValidation } = require("../validations/snippet"); 5 | 6 | const app = Router(); 7 | 8 | app.get("/list", async (req, res) => { 9 | res.json(await listSnippets(req.user.id)); 10 | }); 11 | 12 | app.get("/:snippetId", async (req, res) => { 13 | const snippet = await getSnippet(req.user.id, req.params.snippetId); 14 | if (snippet?.code) return res.json(snippet); 15 | 16 | res.json(snippet); 17 | }); 18 | 19 | app.put("/", async (req, res) => { 20 | if (validateSchema(res, snippetCreationValidation, req.body)) return; 21 | 22 | const snippet = await createSnippet(req.user.id, req.body); 23 | if (snippet?.code) return res.json(snippet); 24 | 25 | res.json({ message: "Snippet created successfully", id: snippet.id }); 26 | }); 27 | 28 | app.patch("/:snippetId", async (req, res) => { 29 | if (validateSchema(res, snippetEditValidation, req.body)) return; 30 | 31 | const snippet = await editSnippet(req.user.id, req.params.snippetId, req.body); 32 | if (snippet?.code) return res.json(snippet); 33 | 34 | res.json({ message: "Snippet updated successfully" }); 35 | }); 36 | 37 | app.delete("/:snippetId", async (req, res) => { 38 | const snippet = await deleteSnippet(req.user.id, req.params.snippetId); 39 | if (snippet?.code) return res.json(snippet); 40 | 41 | res.json({ message: "Snippet deleted successfully" }); 42 | }); 43 | 44 | module.exports = app; -------------------------------------------------------------------------------- /server/routes/sshd.js: -------------------------------------------------------------------------------- 1 | const prepareSSH = require("../utils/sshPreCheck"); 2 | 3 | module.exports = async (ws, req) => { 4 | const ssh = await prepareSSH(ws, req); 5 | if (!ssh) return; 6 | 7 | ssh.on("ready", () => { 8 | ssh.shell({ term: "xterm-256color" }, (err, stream) => { 9 | if (err) { 10 | ws.close(4008, err.message); 11 | return; 12 | } 13 | 14 | stream.on("close", () => ws.close()); 15 | 16 | stream.on("data", (data) => ws.send(data.toString())); 17 | 18 | ws.on("message", (data) => { 19 | if (data.startsWith("\x01")) { 20 | const [width, height] = data.substring(1).split(",").map(Number); 21 | stream.setWindow(height, width); 22 | } else { 23 | stream.write(data); 24 | } 25 | }); 26 | 27 | ws.on("close", () => { 28 | stream.end(); 29 | ssh.end(); 30 | }); 31 | }); 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /server/routes/users.js: -------------------------------------------------------------------------------- 1 | const { Router } = require("express"); 2 | const { listUsers, createAccount, deleteAccount, updatePassword, updateRole } = require("../controllers/account"); 3 | const { validateSchema } = require("../utils/schema"); 4 | const { createUserValidation, updateRoleValidation } = require("../validations/users"); 5 | const { createSession } = require("../controllers/session"); 6 | const { passwordChangeValidation } = require("../validations/account"); 7 | 8 | const app = Router(); 9 | 10 | app.get("/list", async (req, res) => { 11 | res.json(await listUsers()); 12 | }); 13 | 14 | app.put("/", async (req, res) => { 15 | if (validateSchema(res, createUserValidation, req.body)) return; 16 | 17 | const account = await createAccount(req.body, false); 18 | if (account?.code) return res.json(account); 19 | 20 | res.json({ message: "Account got successfully created" }); 21 | }); 22 | 23 | app.post("/:accountId/login", async (req, res) => { 24 | const account = await createSession(req.params.accountId, req.headers["user-agent"]); 25 | if (account?.code) return res.json(account); 26 | 27 | res.json({ message: "Session got successfully created", token: account.token }); 28 | }); 29 | 30 | app.delete("/:accountId", async (req, res) => { 31 | const account = await deleteAccount(req.params.accountId); 32 | if (account?.code) return res.json(account); 33 | 34 | res.json({ message: "Account got successfully deleted" }); 35 | }); 36 | 37 | app.patch("/:accountId/password", async (req, res) => { 38 | if (validateSchema(res, passwordChangeValidation, req.body)) return; 39 | 40 | const account = await updatePassword(req.params.accountId, req.body.password); 41 | if (account?.code) return res.json(account); 42 | 43 | res.json({ message: "Password got successfully updated" }); 44 | }); 45 | 46 | app.patch("/:accountId/role", async (req, res) => { 47 | try { 48 | if (req.user.id === parseInt(req.params.accountId)) 49 | return res.json({ code: 107, message: "You cannot change your own role" }); 50 | 51 | if (validateSchema(res, updateRoleValidation, req.body)) return; 52 | 53 | const account = await updateRole(req.params.accountId, req.body.role); 54 | if (account?.code) return res.json(account); 55 | 56 | res.json({ message: "Role got successfully updated" }); 57 | } catch (error) { 58 | res.json({ code: 109, message: "You need to provide a correct id"}); 59 | } 60 | }); 61 | 62 | module.exports = app; -------------------------------------------------------------------------------- /server/utils/apps/checkDistro.js: -------------------------------------------------------------------------------- 1 | module.exports.checkDistro = (ssh, ws) => { 2 | return new Promise((resolve, reject) => { 3 | ssh.exec("cat /etc/os-release", (err, stream) => { 4 | if (err) return reject(new Error("Failed to check distro")); 5 | 6 | let data = ""; 7 | 8 | stream.on("close", () => { 9 | let distro = null; 10 | let version = null; 11 | 12 | data.split("\n").forEach(line => { 13 | if (line.startsWith("ID=")) { 14 | distro = line.split("=")[1].replace(/"/g, "") 15 | .replace(/./, c => c.toUpperCase()); 16 | } 17 | if (line.startsWith("VERSION_ID=")) { 18 | version = line.split("=")[1].replace(/"/g, ""); 19 | } 20 | }); 21 | 22 | if (distro && version) { 23 | ws.send(`\x021,${distro},${version}`); 24 | resolve(); 25 | } else { 26 | reject(new Error("Failed to parse distro information")); 27 | } 28 | }); 29 | 30 | stream.on("data", newData => { 31 | data += newData.toString(); 32 | }); 33 | 34 | stream.stderr.on("data", () => reject(new Error("Failed to check distro"))); 35 | }); 36 | }); 37 | } -------------------------------------------------------------------------------- /server/utils/apps/checkPermissions.js: -------------------------------------------------------------------------------- 1 | const checkSudoPermissions = (ssh, ws, identity) => { 2 | return new Promise((resolve, reject) => { 3 | ssh.exec("sudo -n true", (err, stream) => { 4 | if (err) return reject(new Error("Failed to check sudo permissions")); 5 | 6 | stream.on("data", () => {}); 7 | 8 | stream.on("close", (code) => { 9 | if (code === 0) { 10 | ws.send(`\x022,Sudo access granted`); 11 | return resolve("sudo "); 12 | } 13 | 14 | ssh.exec(`echo ${identity.password} | sudo -S true`, (err, stream) => { 15 | if (err) return reject(new Error("Failed to check sudo permissions")); 16 | 17 | stream.on("data", () => {}); 18 | 19 | stream.on("close", (code) => { 20 | if (code === 0) { 21 | ws.send(`\x022,Sudo access granted`); 22 | return resolve(`echo ${identity.password} | sudo -S `); 23 | } 24 | 25 | ws.send(`\x021,Sudo access denied`); 26 | reject(new Error("Sudo access denied")); 27 | 28 | }); 29 | }); 30 | }); 31 | }); 32 | }); 33 | } 34 | 35 | module.exports.checkPermissions = (ssh, ws, identity) => { 36 | return new Promise((resolve, reject) => { 37 | ssh.exec("id -u", (err, stream) => { 38 | if (err) return reject(new Error("Failed to check permissions")); 39 | 40 | stream.on("data", data => { 41 | const userId = data.toString().trim(); 42 | if (userId === "0") { 43 | ws.send(`\x022,Root permissions detected`); 44 | resolve(""); 45 | } else { 46 | checkSudoPermissions(ssh, ws, identity).then(resolve).catch(reject); 47 | } 48 | }); 49 | 50 | stream.stderr.on("data", err => reject(new Error("Failed to check permissions"))); 51 | }); 52 | }); 53 | } -------------------------------------------------------------------------------- /server/utils/apps/installDocker.js: -------------------------------------------------------------------------------- 1 | module.exports.installDocker = (ssh, ws, cmdPrefix) => { 2 | return new Promise((resolve, reject) => { 3 | ssh.exec(`${cmdPrefix}docker --version && (${cmdPrefix}docker-compose --version || ${cmdPrefix}docker compose version)`, (err, stream) => { 4 | let dockerInstalled = false; 5 | 6 | stream.on("data", () => { 7 | dockerInstalled = true; 8 | ws.send("\x023,Docker and Docker Compose are already installed"); 9 | resolve(); 10 | }); 11 | 12 | stream.stderr.on("data", () => { 13 | if (!dockerInstalled) { 14 | ssh.exec(`curl -fsSL https://get.docker.com | ${cmdPrefix}sh`, (err, stream) => { 15 | if (err) { 16 | return reject(new Error("Failed to install Docker using the installation script")); 17 | } 18 | 19 | stream.on("data", (data) => { 20 | ws.send("\x01" + data.toString()); 21 | }); 22 | 23 | stream.on("close", () => { 24 | ssh.exec(`${cmdPrefix}docker --version && (${cmdPrefix}docker-compose --version || ${cmdPrefix}docker compose version)`, (err, stream) => { 25 | if (err) { 26 | return reject(new Error("Failed to verify Docker installation")); 27 | } 28 | 29 | stream.on("data", () => { 30 | ws.send("\x023,Docker and Docker Compose installed successfully"); 31 | resolve(); 32 | }); 33 | 34 | stream.stderr.on("data", () => { 35 | reject(new Error("Docker or Docker Compose not installed correctly")); 36 | }); 37 | }); 38 | }); 39 | }); 40 | } 41 | }); 42 | }); 43 | }); 44 | }; -------------------------------------------------------------------------------- /server/utils/apps/runCommand.js: -------------------------------------------------------------------------------- 1 | module.exports.runPreInstallCommand = (ssh, ws, preInstallCommand, cmdPrefix) => { 2 | return new Promise((resolve, reject) => { 3 | ssh.exec(cmdPrefix + preInstallCommand, (err, stream) => { 4 | if (err) return reject(new Error("Failed to run pre-install command")); 5 | 6 | stream.on("data", (data) => { 7 | ws.send("\x01" + data.toString()); 8 | }); 9 | 10 | ws.send("\x025,Pre-install command completed"); 11 | resolve(); 12 | }); 13 | }); 14 | } 15 | 16 | module.exports.runPostInstallCommand = (ssh, ws, postInstallCommand, cmdPrefix) => { 17 | return new Promise((resolve, reject) => { 18 | ssh.exec(cmdPrefix + postInstallCommand, (err, stream) => { 19 | if (err) return reject(new Error("Failed to run post-install command")); 20 | 21 | stream.on("data", (data) => { 22 | ws.send("\x01" + data.toString()); 23 | }); 24 | 25 | ws.send("\x027,Post-install command completed"); 26 | resolve(); 27 | }); 28 | }); 29 | } -------------------------------------------------------------------------------- /server/utils/apps/startContainer.js: -------------------------------------------------------------------------------- 1 | module.exports.startContainer = startContainer = (ssh, ws, appId, resolve, reject, useStandalone = true, cmdPrefix) => { 2 | if (!resolve || !reject) { 3 | return new Promise((resolve, reject) => { 4 | startContainer(ssh, ws, appId, resolve, reject, useStandalone, cmdPrefix); 5 | }); 6 | } 7 | 8 | const command = `cd /opt/nexterm_apps/${appId.replace("/", "_")} && ${cmdPrefix}${useStandalone ? "docker-compose" : "docker compose"} up -d`; 9 | 10 | ssh.exec(command, (err, stream) => { 11 | if (err) { 12 | console.log(err) 13 | return reject(new Error("Failed to start container")); 14 | } 15 | stream.on("data", (data) => { 16 | ws.send("\x01" + data.toString()); 17 | }); 18 | 19 | stream.stderr.on("data", (data) => { 20 | ws.send("\x01" + data.toString()); 21 | }); 22 | 23 | stream.on("close", (code) => { 24 | if (code !== 0) { 25 | if (useStandalone) { 26 | return startContainer(ssh, ws, appId, resolve, reject, false, cmdPrefix); 27 | } else { 28 | return reject(new Error("Failed to start container")); 29 | } 30 | } 31 | 32 | ws.send("\x026,Container started"); 33 | resolve(); 34 | }); 35 | 36 | stream.on("error", (streamErr) => { 37 | return reject(new Error(`Stream error: ${streamErr.message}`)); 38 | }); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /server/utils/database.js: -------------------------------------------------------------------------------- 1 | const {Sequelize} = require('sequelize'); 2 | 3 | const STORAGE_PATH = `data/nexterm.db`; 4 | 5 | Sequelize.DATE.prototype._stringify = () => { 6 | return new Date().toISOString(); 7 | } 8 | 9 | if (process.env.DB_TYPE === "mysql") { 10 | if (!process.env.DB_NAME || !process.env.DB_PASS || !process.env.DB_USER) 11 | throw new Error("Missing database environment variables"); 12 | 13 | module.exports = new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASS, { 14 | host: process.env.DB_HOST || "localhost", 15 | dialect: 'mysql', 16 | logging: false, 17 | query: {raw: true} 18 | }); 19 | } else if (!process.env.DB_TYPE || process.env.DB_TYPE === "sqlite") { 20 | module.exports = new Sequelize({dialect: 'sqlite', storage: STORAGE_PATH, logging: false, query: {raw: true}}); 21 | } else { 22 | throw new Error("Invalid database type"); 23 | } -------------------------------------------------------------------------------- /server/utils/encryption.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const algorithm = "aes-256-gcm"; 3 | 4 | const getEncryptionKey = () => { 5 | const key = process.env.ENCRYPTION_KEY; 6 | if (!key) { 7 | throw new Error("ENCRYPTION_KEY not found in environment variables"); 8 | } 9 | return key; 10 | }; 11 | 12 | const encrypt = (text) => { 13 | if (!text) return null; 14 | 15 | const iv = crypto.randomBytes(16); 16 | const cipher = crypto.createCipheriv( 17 | algorithm, 18 | Buffer.from(getEncryptionKey(), "hex"), 19 | iv 20 | ); 21 | 22 | let encrypted = cipher.update(text, "utf8", "hex"); 23 | encrypted += cipher.final("hex"); 24 | 25 | const authTag = cipher.getAuthTag(); 26 | 27 | return { 28 | encrypted: encrypted, 29 | iv: iv.toString("hex"), 30 | authTag: authTag.toString("hex"), 31 | }; 32 | }; 33 | 34 | const decrypt = (encrypted, iv, authTag) => { 35 | if (!encrypted || !iv || !authTag) return null; 36 | 37 | const decipher = crypto.createDecipheriv( 38 | algorithm, 39 | Buffer.from(getEncryptionKey(), "hex"), 40 | Buffer.from(iv, "hex") 41 | ); 42 | 43 | decipher.setAuthTag(Buffer.from(authTag, "hex")); 44 | 45 | let decrypted = decipher.update(encrypted, "hex", "utf8"); 46 | decrypted += decipher.final("utf8"); 47 | 48 | return decrypted; 49 | }; 50 | 51 | module.exports = { encrypt, decrypt }; 52 | -------------------------------------------------------------------------------- /server/utils/error.js: -------------------------------------------------------------------------------- 1 | module.exports.sendError = (res, httpCode, errorCode, message) => 2 | res.status(httpCode).json({ code: errorCode, message }); -------------------------------------------------------------------------------- /server/utils/errorHandling.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const filePath = process.cwd() + "/data/logs/error.log"; 3 | 4 | module.exports = (error) => { 5 | const date = new Date().toLocaleString(); 6 | const lineStarter = fs.existsSync(filePath) ? "\n\n" : "# Found a bug? Report it here: https://github.com/gnmyt/Nexterm/issues\n\n"; 7 | 8 | console.error("An error occurred: " + error.message); 9 | 10 | fs.writeFile(filePath, lineStarter + "## " + date + "\n" + error, {flag: 'a+'}, err => { 11 | if (err) console.error("Could not save error log file.", error); 12 | 13 | process.exit(1); 14 | }); 15 | } -------------------------------------------------------------------------------- /server/utils/folder.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const neededFolder = ["data", "data/logs", "data/sources"]; 4 | 5 | neededFolder.forEach(folder => { 6 | if (!fs.existsSync(folder)) { 7 | try { 8 | fs.mkdirSync(folder, {recursive: true}); 9 | } catch (e) { 10 | console.error("Could not create the data folder. Please check the permission"); 11 | process.exit(0); 12 | } 13 | } 14 | }); -------------------------------------------------------------------------------- /server/utils/permission.js: -------------------------------------------------------------------------------- 1 | const OrganizationMember = require("../models/OrganizationMember"); 2 | const Folder = require("../models/Folder"); 3 | 4 | exports.hasOrganizationAccess = async (accountId, organizationId) => { 5 | if (!organizationId) return false; 6 | 7 | const membership = await OrganizationMember.findOne({ where: { accountId, organizationId, status: "active" } }); 8 | 9 | return !!membership; 10 | }; 11 | 12 | exports.validateFolderAccess = async (accountId, folderId) => { 13 | const folder = await Folder.findByPk(folderId); 14 | if (!folder) { 15 | return { valid: false, error: { code: 301, message: "Folder does not exist" } }; 16 | } 17 | 18 | if (folder.organizationId) { 19 | const hasAccess = await exports.hasOrganizationAccess(accountId, folder.organizationId); 20 | if (!hasAccess) { 21 | return { valid: false, error: { code: 403, message: "You don't have access to this organization" } }; 22 | } 23 | } else { 24 | if (folder.accountId !== accountId) { 25 | return { valid: false, error: { code: 403, message: "You don't have access to this folder" } }; 26 | } 27 | } 28 | 29 | return { valid: true, folder }; 30 | }; -------------------------------------------------------------------------------- /server/utils/prepareSSH.js: -------------------------------------------------------------------------------- 1 | const sshd = require("ssh2"); 2 | 3 | module.exports = (server, identity, ws, res) => { 4 | const options = { 5 | host: server.ip, 6 | port: server.port, 7 | username: identity.username, 8 | tryKeyboard: true, 9 | ...(identity.type === "password" ? { password: identity.password } : { privateKey: identity.sshKey, passphrase: identity.passphrase }) 10 | }; 11 | 12 | let ssh = new sshd.Client(); 13 | if (ws) { 14 | ssh.on("error", (error) => { 15 | if(error.level === "client-timeout") { 16 | ws.close(4007, "Client Timeout reached"); 17 | } else { 18 | ws.close(4005, error.message); 19 | } 20 | }); 21 | 22 | ssh.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => { 23 | ws.send(`\x02${prompts[0].prompt}`); 24 | 25 | ws.on("message", (data) => { 26 | if (data.toString().startsWith("\x03")) { 27 | const totpCode = data.substring(1); 28 | finish([totpCode]); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | try { 35 | ssh.connect(options); 36 | } catch (err) { 37 | if (ws) ws.close(4004, err.message); 38 | if (res) res.status(500).send(err.message); 39 | } 40 | 41 | if (ws) { 42 | ssh.on("end", () => { 43 | ws.close(4006, "Connection closed"); 44 | }); 45 | 46 | ssh.on("exit", () => { 47 | ws.close(4006, "Connection exited"); 48 | }); 49 | 50 | ssh.on("close", () => { 51 | ws.close(4007, "Connection closed"); 52 | }); 53 | } 54 | 55 | if (ws) { 56 | console.log("Authorized connection to server " + server.ip + " with identity " + identity.name); 57 | } else { 58 | console.log("Authorized file download from server " + server.ip + " with identity " + identity.name); 59 | } 60 | 61 | return ssh; 62 | } -------------------------------------------------------------------------------- /server/utils/schema.js: -------------------------------------------------------------------------------- 1 | module.exports.validateSchema = (res, schema, object) => { 2 | const { error } = schema.validate(object, { errors: { wrap: { label: "" } } }); 3 | const message = error?.details[0].message || "No message provided"; 4 | 5 | if (error) res.status(400).json({ message }); 6 | 7 | return error; 8 | }; 9 | -------------------------------------------------------------------------------- /server/utils/sshPreCheck.js: -------------------------------------------------------------------------------- 1 | const Session = require("../models/Session"); 2 | const Account = require("../models/Account"); 3 | const Server = require("../models/Server"); 4 | const Identity = require("../models/Identity"); 5 | const prepareSSH = require("./prepareSSH"); 6 | const { validateServerAccess } = require("../controllers/server"); 7 | 8 | module.exports = async (ws, req) => { 9 | const authHeader = req.query["sessionToken"]; 10 | const serverId = req.query["serverId"]; 11 | const identityId = req.query["identityId"]; 12 | 13 | if (!authHeader) { 14 | ws.close(4001, "You need to provide the token in the 'sessionToken' parameter"); 15 | return; 16 | } 17 | 18 | if (!serverId) { 19 | ws.close(4002, "You need to provide the serverId in the 'serverId' parameter"); 20 | return; 21 | } 22 | 23 | if (!identityId) { 24 | ws.close(4003, "You need to provide the identity in the 'identityId' parameter"); 25 | return; 26 | } 27 | 28 | req.session = await Session.findOne({ where: { token: authHeader } }); 29 | 30 | if (req.session === null) { 31 | ws.close(4003, "The token is not valid"); 32 | return; 33 | } 34 | 35 | await Session.update({ lastActivity: new Date() }, { where: { id: req.session.id } }); 36 | 37 | req.user = await Account.findByPk(req.session.accountId); 38 | if (req.user === null) { 39 | ws.close(4004, "The token is not valid"); 40 | return; 41 | } 42 | 43 | const server = await Server.findByPk(serverId); 44 | if (server === null) return; 45 | 46 | if (!((await validateServerAccess(req.user.id, server)).valid)) { 47 | ws.close(4005, "You don't have access to this server"); 48 | return; 49 | } 50 | 51 | if (server.identities.length === 0 && identityId) return; 52 | 53 | const identity = await Identity.findByPk(identityId || server.identities[0]); 54 | if (identity === null) return; 55 | 56 | return prepareSSH(server, identity, ws); 57 | } -------------------------------------------------------------------------------- /server/utils/tokenGenerator.js: -------------------------------------------------------------------------------- 1 | module.exports.createVNCToken = (hostname, port, username, password, keyboardLayout = "en-us-qwerty") => { 2 | return { 3 | connection: { 4 | type: "vnc", 5 | settings: { 6 | hostname, 7 | port, 8 | password, 9 | "ignore-cert": true, 10 | "resize-method": "display-update", 11 | "server-layout": keyboardLayout, 12 | }, 13 | }, 14 | }; 15 | }; 16 | 17 | module.exports.createRDPToken = (hostname, port, username, password, keyboardLayout = "en-us-qwerty") => { 18 | let domain = ""; 19 | if (username.includes("\\")) [domain, username] = username.split("\\"); 20 | return { 21 | connection: { 22 | type: "rdp", 23 | settings: { 24 | hostname, username, port, password, domain, "ignore-cert": true, "resize-method": "display-update", 25 | "enable-wallpaper": true, "enable-theming": true, "server-layout": keyboardLayout, 26 | }, 27 | }, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /server/validations/account.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | module.exports.registerValidation = Joi.object({ 4 | username: Joi.string().min(3).max(15).alphanum().required(), 5 | password: Joi.string().min(3).max(150).required(), 6 | firstName: Joi.string().min(2).max(50).required(), 7 | lastName: Joi.string().min(2).max(50).required(), 8 | }); 9 | 10 | module.exports.totpSetup = Joi.object({ 11 | code: Joi.number().integer().required(), 12 | }); 13 | 14 | module.exports.passwordChangeValidation = Joi.object({ 15 | password: Joi.string().min(3).max(150).required(), 16 | }); 17 | 18 | module.exports.updateNameValidation = Joi.object({ 19 | firstName: Joi.string().min(2).max(50), 20 | lastName: Joi.string().min(2).max(50), 21 | }).or('firstName', 'lastName'); -------------------------------------------------------------------------------- /server/validations/appSource.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | module.exports.appObject = Joi.object({ 4 | name: Joi.string().required(), 5 | version: Joi.string().required(), 6 | description: Joi.string().required(), 7 | icon: Joi.string().uri().required(), 8 | preInstallCommand: Joi.string(), 9 | postInstallCommand: Joi.string(), 10 | category: Joi.string().required(), 11 | port: Joi.number().required() 12 | }); 13 | 14 | module.exports.createAppSourceValidation = Joi.object({ 15 | name: Joi.string().alphanum().required(), 16 | url: Joi.string().uri().regex(/\.zip$/).required() 17 | }); 18 | 19 | module.exports.updateAppUrlValidation = Joi.object({ 20 | url: Joi.string().uri().regex(/\.zip$/).required() 21 | }); -------------------------------------------------------------------------------- /server/validations/auth.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | module.exports.loginValidation = Joi.object({ 4 | username: Joi.string().min(3).max(15).alphanum().required(), 5 | password: Joi.string().min(5).max(50).required(), 6 | code: Joi.number().integer() 7 | }); 8 | 9 | module.exports.tokenValidation = Joi.object({ 10 | token: Joi.string().hex().length(96).required() 11 | }); -------------------------------------------------------------------------------- /server/validations/folder.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | module.exports.folderCreationValidation = Joi.object({ 4 | name: Joi.string().min(3).max(50).required(), 5 | parentId: Joi.number().integer(), 6 | organizationId: Joi.number().integer() 7 | }); 8 | 9 | module.exports.folderEditValidation = Joi.object({ 10 | name: Joi.string().min(3).max(50), 11 | parentId: Joi.number().integer() 12 | }).min(1); -------------------------------------------------------------------------------- /server/validations/identity.js: -------------------------------------------------------------------------------- 1 | const Joi = require("joi"); 2 | module.exports.createIdentityValidation = Joi.object({ 3 | name: Joi.string().min(3).max(255).required(), 4 | username: Joi.string().max(255).optional(), 5 | type: Joi.string().valid("password", "ssh").required(), 6 | password: Joi.string().optional(), 7 | sshKey: Joi.string().optional(), 8 | passphrase: Joi.string().optional(), 9 | }).xor("password", "sshKey"); 10 | 11 | module.exports.updateIdentityValidation = Joi.object({ 12 | name: Joi.string().min(3).max(255).optional(), 13 | username: Joi.string().max(255).optional(), 14 | type: Joi.string().valid("password", "ssh").optional(), 15 | password: Joi.string().optional(), 16 | sshKey: Joi.string().optional(), 17 | passphrase: Joi.string().optional(), 18 | }).or("name", "username", "type", "password", "sshKey", "passphrase"); -------------------------------------------------------------------------------- /server/validations/oidc.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | module.exports.oidcProviderValidation = Joi.object({ 4 | name: Joi.string().min(1).max(50).required(), 5 | issuer: Joi.string().uri().required(), 6 | clientId: Joi.string().required(), 7 | clientSecret: Joi.string().allow('', null), 8 | redirectUri: Joi.string().uri().required(), 9 | scope: Joi.string().default('openid profile email'), 10 | enabled: Joi.boolean().default(false), 11 | emailAttribute: Joi.string().default('email'), 12 | firstNameAttribute: Joi.string().default('given_name'), 13 | lastNameAttribute: Joi.string().default('family_name'), 14 | usernameAttribute: Joi.string().default('preferred_username') 15 | }); 16 | 17 | module.exports.oidcProviderUpdateValidation = Joi.object({ 18 | name: Joi.string().min(1).max(50), 19 | issuer: Joi.string().uri(), 20 | clientId: Joi.string(), 21 | clientSecret: Joi.string().allow('', null), 22 | redirectUri: Joi.string().uri(), 23 | scope: Joi.string(), 24 | enabled: Joi.boolean(), 25 | emailAttribute: Joi.string(), 26 | firstNameAttribute: Joi.string(), 27 | lastNameAttribute: Joi.string(), 28 | usernameAttribute: Joi.string() 29 | }); -------------------------------------------------------------------------------- /server/validations/organization.js: -------------------------------------------------------------------------------- 1 | const Joi = require("joi"); 2 | 3 | module.exports.createOrganizationSchema = Joi.object({ 4 | name: Joi.string().min(1).max(100).required(), 5 | description: Joi.string().max(500).allow("", null), 6 | }); 7 | 8 | module.exports.updateOrganizationSchema = Joi.object({ 9 | name: Joi.string().min(1).max(100), 10 | description: Joi.string().max(500).allow("", null), 11 | }); 12 | 13 | module.exports.inviteUserSchema = Joi.object({ 14 | username: Joi.string().required(), 15 | }); 16 | 17 | module.exports.respondToInvitationSchema = Joi.object({ 18 | accept: Joi.boolean().required(), 19 | }); -------------------------------------------------------------------------------- /server/validations/pveServer.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | module.exports.createPVEServerValidation = Joi.object({ 4 | name: Joi.string().required(), 5 | folderId: Joi.number().required(), 6 | ip: Joi.string().required(), 7 | port: Joi.number().required(), 8 | username: Joi.string().required(), 9 | password: Joi.string().required(), 10 | }); 11 | 12 | module.exports.updatePVEServerValidation = Joi.object({ 13 | name: Joi.string().optional(), 14 | folderId: Joi.number().optional(), 15 | ip: Joi.string().optional(), 16 | port: Joi.number().optional(), 17 | username: Joi.string().optional(), 18 | password: Joi.string().optional(), 19 | }); -------------------------------------------------------------------------------- /server/validations/server.js: -------------------------------------------------------------------------------- 1 | const Joi = require("joi"); 2 | 3 | const configValidation = Joi.object({ 4 | keyboardLayout: Joi.string().optional() 5 | }); 6 | 7 | module.exports.createServerValidation = Joi.object({ 8 | name: Joi.string().required(), 9 | folderId: Joi.number().required(), 10 | icon: Joi.string().optional(), 11 | protocol: Joi.string().valid("ssh", "rdp", "vnc").required(), 12 | ip: Joi.string().required(), 13 | port: Joi.number().required(), 14 | identities: Joi.array().items(Joi.number()).optional(), 15 | config: configValidation 16 | }); 17 | 18 | module.exports.updateServerValidation = Joi.object({ 19 | name: Joi.string().optional(), 20 | folderId: Joi.number().optional(), 21 | icon: Joi.string().optional(), 22 | protocol: Joi.string().valid("ssh", "rdp", "vnc").optional(), 23 | ip: Joi.string().optional(), 24 | port: Joi.number().optional(), 25 | position: Joi.number().optional(), 26 | identities: Joi.array().items(Joi.number()).optional(), 27 | config: configValidation 28 | }); -------------------------------------------------------------------------------- /server/validations/snippet.js: -------------------------------------------------------------------------------- 1 | const Joi = require("joi"); 2 | 3 | module.exports.snippetCreationValidation = Joi.object({ 4 | name: Joi.string().min(1).max(255).required(), 5 | command: Joi.string().min(1).required(), 6 | description: Joi.string().allow(null, ""), 7 | }); 8 | 9 | module.exports.snippetEditValidation = Joi.object({ 10 | name: Joi.string().min(1).max(255), 11 | command: Joi.string().min(1), 12 | description: Joi.string().allow(null, "") 13 | }); -------------------------------------------------------------------------------- /server/validations/users.js: -------------------------------------------------------------------------------- 1 | const Joi = require("joi"); 2 | 3 | module.exports.createUserValidation = Joi.object({ 4 | username: Joi.string().min(3).max(15).alphanum().required(), 5 | password: Joi.string().min(3).max(150).required(), 6 | firstName: Joi.string().min(2).max(50).required(), 7 | lastName: Joi.string().min(2).max(50).required(), 8 | role: Joi.string().valid("user", "admin").optional(), 9 | }); 10 | 11 | module.exports.updateRoleValidation = Joi.object({ 12 | role: Joi.string().valid("user", "admin").required() 13 | }); --------------------------------------------------------------------------------