├── .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 |
30 |
31 |
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 |
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 |

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

11 |
12 |
13 |
14 |
{title}
15 |
Version {version}
16 |
17 |
18 |
19 |
{description}
20 |
21 |
22 |
23 |
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 |

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 |
54 |
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 |
18 |
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 |
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 |
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 |

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 |
31 |
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 |
32 |
33 |
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 |
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 |
42 |
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 | 
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 | });
--------------------------------------------------------------------------------