Compare commits
57 Commits
release-1.
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c1cacc9d | ||
|
|
8dddbaa86d | ||
|
|
d46fafb421 | ||
|
|
25178928a0 | ||
|
|
2fe9c0f854 | ||
|
|
2f68dc018e | ||
|
|
8b8e77214c | ||
|
|
89589dcf9f | ||
|
|
250ad975d4 | ||
|
|
b649e73c80 | ||
|
|
839e36adb9 | ||
|
|
f02c0c3163 | ||
|
|
83c41751ea | ||
|
|
8058ffd217 | ||
|
|
a3db62b0f8 | ||
|
|
dc43bf1329 | ||
|
|
0a7b1b2bd0 | ||
|
|
94e6617638 | ||
|
|
76995dbc1b | ||
|
|
be6cda7d8a | ||
|
|
9130eb68a8 | ||
|
|
200428498f | ||
|
|
f60c9c72aa | ||
|
|
487919cedc | ||
|
|
b046aedcee | ||
|
|
0c5216933a | ||
|
|
191dc8ba24 | ||
|
|
d88c890ba7 | ||
|
|
a34c60947d | ||
|
|
56fddb6fcb | ||
|
|
b9bd00f86e | ||
|
|
d1ae7f659d | ||
|
|
b127c5ff4f | ||
|
|
75a87400eb | ||
|
|
d4dccdb574 | ||
|
|
a34421f5b2 | ||
|
|
7a5cf94d45 | ||
|
|
134f58b38b | ||
|
|
84dcda9363 | ||
|
|
d81cf20f5e | ||
|
|
86fd8574f1 | ||
|
|
6e175e2b36 | ||
|
|
c7fba8dbb1 | ||
|
|
0c2ac176a7 | ||
|
|
59a38dad0a | ||
|
|
6ac536ebad | ||
|
|
12418eb5b2 | ||
|
|
e364e9b38e | ||
|
|
402f9fa909 | ||
|
|
5ac25e2a26 | ||
|
|
5fbac89e67 | ||
|
|
f2fb938e5f | ||
|
|
cef420d1d8 | ||
|
|
94f69cee3f | ||
|
|
23e72aedfd | ||
|
|
7957ed06e4 | ||
|
|
5f1a2d9a17 |
8
.github/workflows/docker-image.yml
vendored
8
.github/workflows/docker-image.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
network=host
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and Push Multi-Arch Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
|
||||
56
CONTRIBUTING.md
Normal file
56
CONTRIBUTING.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) (built with v24)
|
||||
- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone https://github.com/LukeGus/Termix
|
||||
```
|
||||
2. Install the dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running the development server
|
||||
|
||||
Run the following commands:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
npx tsc -p tsconfig.node.json
|
||||
node ./dist/backend/starter.js
|
||||
```
|
||||
|
||||
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. **Fork the repository**: Click the "Fork" button at the top right of
|
||||
the [repository page](https://github.com/LukeGus/Termix).
|
||||
2. **Create a new branch**:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||
4. **Commit your changes**:
|
||||
```sh
|
||||
git commit -m "Add feature: my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
```sh
|
||||
git push origin feature/my-new-feature
|
||||
```
|
||||
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
||||
|
||||
## 📝 Guidelines
|
||||
|
||||
- Follow the existing code style. Use Tailwind CSS with shadcn components.
|
||||
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
|
||||
- Include meaningful commit messages.
|
||||
- Link related issues when applicable.
|
||||
15
README.md
15
README.md
@@ -23,6 +23,12 @@ If you would like, you can support the project here!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Overview
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/LukeGus/Termix">
|
||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
|
||||
|
||||
# Features
|
||||
@@ -31,18 +37,17 @@ Termix is an open-source, forever-free, self-hosted all-in-one server management
|
||||
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files)
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
|
||||
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
|
||||
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
|
||||
|
||||
# Planned Features
|
||||
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
|
||||
- **More auth types** - Add 2FA, TOTP, etc
|
||||
- **Theming** - Modify themeing for all tools
|
||||
- **Theming** - Modify theming for all tools
|
||||
- **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue)
|
||||
- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone
|
||||
|
||||
# Installation
|
||||
Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
@@ -78,7 +83,7 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/29e086e5-fabf-413e-a7d9-e535bf63efde" width="800" controls>
|
||||
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Install dependencies and build frontend
|
||||
FROM node:22-alpine AS deps
|
||||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
@@ -26,7 +26,7 @@ COPY . .
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies
|
||||
FROM node:22-alpine AS production-deps
|
||||
FROM node:24-alpine AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
@@ -35,7 +35,7 @@ RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Build native modules
|
||||
FROM node:22-alpine AS native-builder
|
||||
FROM node:24-alpine AS native-builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
@@ -46,7 +46,7 @@ RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final image
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
@@ -71,6 +71,14 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -85,7 +93,6 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# File manager recent, pinned, shortcuts (handled by SSH service)
|
||||
location /ssh/file_manager/recent {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
@@ -113,7 +120,6 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SSH file manager operations (handled by file manager service)
|
||||
location /ssh/file_manager/ssh/ {
|
||||
proxy_pass http://127.0.0.1:8084;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
2262
openapi.json
Normal file
2262
openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
595
package-lock.json
generated
595
package-lock.json
generated
@@ -29,6 +29,8 @@
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
@@ -58,12 +60,14 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
@@ -72,27 +76,27 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -1023,9 +1027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
|
||||
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
|
||||
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -1033,9 +1037,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
||||
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
||||
"version": "0.15.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
|
||||
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -1083,9 +1087,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
|
||||
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
|
||||
"version": "9.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
|
||||
"integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1106,13 +1110,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
|
||||
"integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
|
||||
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.15.1",
|
||||
"@eslint/core": "^0.15.2",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3525,6 +3529,60 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.11",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
||||
@@ -3720,12 +3778,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
|
||||
"integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
|
||||
"version": "24.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
@@ -3781,6 +3848,15 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/speakeasy": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz",
|
||||
"integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
|
||||
@@ -3819,17 +3895,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz",
|
||||
"integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
|
||||
"integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.37.0",
|
||||
"@typescript-eslint/type-utils": "8.37.0",
|
||||
"@typescript-eslint/utils": "8.37.0",
|
||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
||||
"@typescript-eslint/scope-manager": "8.40.0",
|
||||
"@typescript-eslint/type-utils": "8.40.0",
|
||||
"@typescript-eslint/utils": "8.40.0",
|
||||
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -3843,9 +3919,9 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.40.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
@@ -3859,16 +3935,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz",
|
||||
"integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz",
|
||||
"integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.37.0",
|
||||
"@typescript-eslint/types": "8.37.0",
|
||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
||||
"@typescript-eslint/scope-manager": "8.40.0",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3880,18 +3956,18 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz",
|
||||
"integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz",
|
||||
"integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.37.0",
|
||||
"@typescript-eslint/types": "^8.37.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.40.0",
|
||||
"@typescript-eslint/types": "^8.40.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3902,18 +3978,18 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz",
|
||||
"integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz",
|
||||
"integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.37.0",
|
||||
"@typescript-eslint/visitor-keys": "8.37.0"
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"@typescript-eslint/visitor-keys": "8.40.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3924,9 +4000,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz",
|
||||
"integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz",
|
||||
"integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3937,19 +4013,19 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz",
|
||||
"integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz",
|
||||
"integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.37.0",
|
||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
||||
"@typescript-eslint/utils": "8.37.0",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||
"@typescript-eslint/utils": "8.40.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
@@ -3962,13 +4038,13 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz",
|
||||
"integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz",
|
||||
"integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3980,16 +4056,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz",
|
||||
"integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz",
|
||||
"integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.37.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.37.0",
|
||||
"@typescript-eslint/types": "8.37.0",
|
||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
||||
"@typescript-eslint/project-service": "8.40.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.40.0",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -4005,7 +4081,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
@@ -4035,16 +4111,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz",
|
||||
"integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz",
|
||||
"integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.37.0",
|
||||
"@typescript-eslint/types": "8.37.0",
|
||||
"@typescript-eslint/typescript-estree": "8.37.0"
|
||||
"@typescript-eslint/scope-manager": "8.40.0",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"@typescript-eslint/typescript-estree": "8.40.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -4055,17 +4131,17 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz",
|
||||
"integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz",
|
||||
"integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.37.0",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4346,6 +4422,15 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -4464,6 +4549,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base32.js": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
|
||||
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -4717,6 +4808,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001727",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||
@@ -4775,6 +4875,17 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -5008,6 +5119,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -5082,6 +5202,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz",
|
||||
@@ -5255,6 +5381,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -5402,20 +5534,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.31.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
|
||||
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
|
||||
"version": "9.34.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
|
||||
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.0",
|
||||
"@eslint/config-helpers": "^0.3.0",
|
||||
"@eslint/core": "^0.15.0",
|
||||
"@eslint/config-helpers": "^0.3.1",
|
||||
"@eslint/core": "^0.15.2",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.31.0",
|
||||
"@eslint/plugin-kit": "^0.3.1",
|
||||
"@eslint/js": "9.34.0",
|
||||
"@eslint/plugin-kit": "^0.3.5",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
@@ -5940,6 +6072,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -6216,6 +6357,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -7141,6 +7291,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -7167,7 +7326,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7211,6 +7369,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -7339,6 +7506,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@@ -7562,6 +7746,21 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -7743,6 +7942,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -7908,6 +8113,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/speakeasy": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base32.js": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz",
|
||||
@@ -7951,6 +8168,32 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -8247,9 +8490,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -8261,16 +8504,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.37.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz",
|
||||
"integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.40.0.tgz",
|
||||
"integrity": "sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.37.0",
|
||||
"@typescript-eslint/parser": "8.37.0",
|
||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
||||
"@typescript-eslint/utils": "8.37.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.40.0",
|
||||
"@typescript-eslint/parser": "8.40.0",
|
||||
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||
"@typescript-eslint/utils": "8.40.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -8281,13 +8524,13 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@@ -8424,16 +8667,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz",
|
||||
"integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==",
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
"picomatch": "^4.0.2",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.40.0",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
},
|
||||
"bin": {
|
||||
@@ -8498,10 +8741,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.4.6",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
@@ -8512,9 +8758,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -8554,6 +8800,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -8564,6 +8816,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -8600,6 +8866,12 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
@@ -8609,6 +8881,93 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
|
||||
16
package.json
16
package.json
@@ -33,6 +33,8 @@
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
@@ -62,12 +64,14 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
@@ -76,26 +80,26 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
67
src/App.tsx
67
src/App.tsx
@@ -2,15 +2,13 @@ import React, {useState, useEffect} from "react"
|
||||
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
|
||||
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
|
||||
import {AppView} from "@/ui/Navigation/AppView.tsx"
|
||||
import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx"
|
||||
import {HostManager} from "@/ui/Apps/Host Manager/HostManager.tsx"
|
||||
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
|
||||
import axios from "axios"
|
||||
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
|
||||
import { AdminSettings } from "@/ui/Admin/AdminSettings";
|
||||
import { UserProfile } from "@/ui/User/UserProfile.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
const API = axios.create({baseURL: apiBase});
|
||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
@@ -39,11 +37,11 @@ function AppContent() {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
setAuthLoading(true);
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}})
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAuthenticated(false);
|
||||
@@ -89,35 +87,24 @@ function AppContent() {
|
||||
const showHome = currentTabData?.type === 'home';
|
||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
||||
const showAdmin = currentTabData?.type === 'admin';
|
||||
const showProfile = currentTabData?.type === 'profile';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div
|
||||
className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 20px,
|
||||
hsl(var(--primary) / 0.4) 20px,
|
||||
hsl(var(--primary) / 0.4) 40px
|
||||
)`
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, hsl(var(--border) / 0.3) 1px, transparent 1px)`,
|
||||
backgroundSize: '40px 40px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
|
||||
<div>
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(
|
||||
135deg,
|
||||
transparent 0%,
|
||||
transparent 49%,
|
||||
rgba(255, 255, 255, 0.03) 49%,
|
||||
rgba(255, 255, 255, 0.03) 51%,
|
||||
transparent 51%,
|
||||
transparent 100%
|
||||
)`,
|
||||
backgroundSize: '80px 80px'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -202,6 +189,20 @@ function AppContent() {
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showProfile ? "visible" : "hidden",
|
||||
pointerEvents: showProfile ? "auto" : "none",
|
||||
height: showProfile ? "100vh" : 0,
|
||||
width: showProfile ? "100%" : 0,
|
||||
position: showProfile ? "static" : "absolute",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<UserProfile isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
|
||||
@@ -405,13 +405,17 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||
try {
|
||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
||||
logger.info('Removed redirect_uri column from users table');
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
||||
|
||||
// Add TOTP columns
|
||||
addColumnIfNotExists('users', 'totp_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT');
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
|
||||
@@ -17,6 +17,10 @@ export const users = sqliteTable('users', {
|
||||
identifier_path: text('identifier_path'),
|
||||
name_path: text('name_path'),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totp_secret: text('totp_secret'),
|
||||
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false),
|
||||
totp_backup_codes: text('totp_backup_codes'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
|
||||
@@ -6,6 +6,8 @@ import chalk from 'chalk';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import {nanoid} from 'nanoid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import speakeasy from 'speakeasy';
|
||||
import QRCode from 'qrcode';
|
||||
import type {Request, Response, NextFunction} from 'express';
|
||||
|
||||
async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise<any> {
|
||||
@@ -79,7 +81,12 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
||||
const key = await importJWK(publicKey);
|
||||
|
||||
const {payload} = await jwtVerify(idToken, key, {
|
||||
issuer: issuerUrl,
|
||||
issuer: [
|
||||
issuerUrl,
|
||||
normalizedIssuerUrl,
|
||||
issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''),
|
||||
normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '')
|
||||
],
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
@@ -201,6 +208,9 @@ router.post('/create', async (req, res) => {
|
||||
identifier_path: '',
|
||||
name_path: '',
|
||||
scopes: 'openid email profile',
|
||||
totp_secret: null,
|
||||
totp_enabled: false,
|
||||
totp_backup_codes: null,
|
||||
});
|
||||
|
||||
logger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`);
|
||||
@@ -541,6 +551,17 @@ router.post('/login', async (req, res) => {
|
||||
expiresIn: '50d',
|
||||
});
|
||||
|
||||
if (userRecord.totp_enabled) {
|
||||
return res.json({
|
||||
requires_totp: true,
|
||||
temp_token: jwt.sign(
|
||||
{userId: userRecord.id, pending_totp: true},
|
||||
jwtSecret,
|
||||
{expiresIn: '10m'}
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
token,
|
||||
is_admin: !!userRecord.is_admin,
|
||||
@@ -574,7 +595,8 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId: user[0].id,
|
||||
username: user[0].username,
|
||||
is_admin: !!user[0].is_admin,
|
||||
is_oidc: !!user[0].is_oidc
|
||||
is_oidc: !!user[0].is_oidc,
|
||||
totp_enabled: !!user[0].totp_enabled
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to get username', err);
|
||||
@@ -924,6 +946,285 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Verify TOTP during login
|
||||
// POST /users/totp/verify-login
|
||||
router.post('/totp/verify-login', async (req, res) => {
|
||||
const {temp_token, totp_code} = req.body;
|
||||
|
||||
if (!temp_token || !totp_code) {
|
||||
return res.status(400).json({error: 'Token and TOTP code are required'});
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(temp_token, jwtSecret) as any;
|
||||
if (!decoded.pending_totp) {
|
||||
return res.status(401).json({error: 'Invalid temporary token'});
|
||||
}
|
||||
|
||||
const user = await db.select().from(users).where(eq(users.id, decoded.userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({error: 'User not found'});
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (!userRecord.totp_enabled || !userRecord.totp_secret) {
|
||||
return res.status(400).json({error: 'TOTP not enabled for this user'});
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totp_secret,
|
||||
encoding: 'base32',
|
||||
token: totp_code,
|
||||
window: 2
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
const backupCodes = userRecord.totp_backup_codes ? JSON.parse(userRecord.totp_backup_codes) : [];
|
||||
const backupIndex = backupCodes.indexOf(totp_code);
|
||||
|
||||
if (backupIndex === -1) {
|
||||
return res.status(401).json({error: 'Invalid TOTP code'});
|
||||
}
|
||||
|
||||
backupCodes.splice(backupIndex, 1);
|
||||
await db.update(users)
|
||||
.set({totp_backup_codes: JSON.stringify(backupCodes)})
|
||||
.where(eq(users.id, userRecord.id));
|
||||
}
|
||||
|
||||
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {
|
||||
expiresIn: '50d',
|
||||
});
|
||||
|
||||
return res.json({
|
||||
token,
|
||||
is_admin: !!userRecord.is_admin,
|
||||
username: userRecord.username
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('TOTP verification failed', err);
|
||||
return res.status(500).json({error: 'TOTP verification failed'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Setup TOTP
|
||||
// POST /users/totp/setup
|
||||
router.post('/totp/setup', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({error: 'User not found'});
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (userRecord.totp_enabled) {
|
||||
return res.status(400).json({error: 'TOTP is already enabled'});
|
||||
}
|
||||
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `Termix (${userRecord.username})`,
|
||||
length: 32
|
||||
});
|
||||
|
||||
await db.update(users)
|
||||
.set({totp_secret: secret.base32})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || '');
|
||||
|
||||
res.json({
|
||||
secret: secret.base32,
|
||||
qr_code: qrCodeUrl
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Failed to setup TOTP', err);
|
||||
res.status(500).json({error: 'Failed to setup TOTP'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Enable TOTP
|
||||
// POST /users/totp/enable
|
||||
router.post('/totp/enable', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const {totp_code} = req.body;
|
||||
|
||||
if (!totp_code) {
|
||||
return res.status(400).json({error: 'TOTP code is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({error: 'User not found'});
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (userRecord.totp_enabled) {
|
||||
return res.status(400).json({error: 'TOTP is already enabled'});
|
||||
}
|
||||
|
||||
if (!userRecord.totp_secret) {
|
||||
return res.status(400).json({error: 'TOTP setup not initiated'});
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totp_secret,
|
||||
encoding: 'base32',
|
||||
token: totp_code,
|
||||
window: 2
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({error: 'Invalid TOTP code'});
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({length: 8}, () =>
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||
);
|
||||
|
||||
await db.update(users)
|
||||
.set({
|
||||
totp_enabled: true,
|
||||
totp_backup_codes: JSON.stringify(backupCodes)
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
res.json({
|
||||
message: 'TOTP enabled successfully',
|
||||
backup_codes: backupCodes
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Failed to enable TOTP', err);
|
||||
res.status(500).json({error: 'Failed to enable TOTP'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Disable TOTP
|
||||
// POST /users/totp/disable
|
||||
router.post('/totp/disable', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const {password, totp_code} = req.body;
|
||||
|
||||
if (!password && !totp_code) {
|
||||
return res.status(400).json({error: 'Password or TOTP code is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({error: 'User not found'});
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (!userRecord.totp_enabled) {
|
||||
return res.status(400).json({error: 'TOTP is not enabled'});
|
||||
}
|
||||
|
||||
if (password && !userRecord.is_oidc) {
|
||||
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({error: 'Incorrect password'});
|
||||
}
|
||||
} else if (totp_code) {
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totp_secret!,
|
||||
encoding: 'base32',
|
||||
token: totp_code,
|
||||
window: 2
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({error: 'Invalid TOTP code'});
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({error: 'Authentication required'});
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({
|
||||
totp_enabled: false,
|
||||
totp_secret: null,
|
||||
totp_backup_codes: null
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
res.json({message: 'TOTP disabled successfully'});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Failed to disable TOTP', err);
|
||||
res.status(500).json({error: 'Failed to disable TOTP'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Generate new backup codes
|
||||
// POST /users/totp/backup-codes
|
||||
router.post('/totp/backup-codes', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const {password, totp_code} = req.body;
|
||||
|
||||
if (!password && !totp_code) {
|
||||
return res.status(400).json({error: 'Password or TOTP code is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({error: 'User not found'});
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (!userRecord.totp_enabled) {
|
||||
return res.status(400).json({error: 'TOTP is not enabled'});
|
||||
}
|
||||
|
||||
if (password && !userRecord.is_oidc) {
|
||||
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({error: 'Incorrect password'});
|
||||
}
|
||||
} else if (totp_code) {
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totp_secret!,
|
||||
encoding: 'base32',
|
||||
token: totp_code,
|
||||
window: 2
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({error: 'Invalid TOTP code'});
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({error: 'Authentication required'});
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({length: 8}, () =>
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||
);
|
||||
|
||||
await db.update(users)
|
||||
.set({totp_backup_codes: JSON.stringify(backupCodes)})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
res.json({backup_codes: backupCodes});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Failed to generate backup codes', err);
|
||||
res.status(500).json({error: 'Failed to generate backup codes'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Delete user (admin only)
|
||||
// DELETE /users/delete-user
|
||||
router.delete('/delete-user', authenticateJWT, async (req, res) => {
|
||||
|
||||
@@ -48,7 +48,6 @@ interface SSHSession {
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
@@ -66,25 +65,26 @@ function scheduleSessionCleanup(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
||||
app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
|
||||
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
||||
if (!sessionId || !ip || !username || !port) {
|
||||
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
||||
}
|
||||
|
||||
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
|
||||
if (sshSessions[sessionId]?.isConnected) {
|
||||
cleanupSession(sessionId);
|
||||
}
|
||||
const client = new SSHClient();
|
||||
const config: any = {
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
username,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 0,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 0,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
@@ -122,8 +122,22 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
||||
};
|
||||
|
||||
if (sshKey && sshKey.trim()) {
|
||||
config.privateKey = sshKey;
|
||||
if (keyPassword) config.passphrase = keyPassword;
|
||||
try {
|
||||
if (!sshKey.includes('-----BEGIN') || !sshKey.includes('-----END')) {
|
||||
throw new Error('Invalid private key format');
|
||||
}
|
||||
|
||||
const cleanKey = sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
config.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
|
||||
if (keyPassword) config.passphrase = keyPassword;
|
||||
|
||||
logger.info('SSH key authentication configured successfully for file manager');
|
||||
} catch (keyError) {
|
||||
logger.error('SSH key format error: ' + keyError.message);
|
||||
return res.status(400).json({error: 'Invalid SSH key format'});
|
||||
}
|
||||
} else if (password && password.trim()) {
|
||||
config.password = password;
|
||||
} else {
|
||||
@@ -136,7 +150,6 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({status: 'success', message: 'SSH connection established'});
|
||||
});
|
||||
|
||||
@@ -181,7 +194,7 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||
@@ -251,7 +264,7 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||
@@ -303,14 +316,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
@@ -331,7 +336,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
fileBuffer = Buffer.from(content);
|
||||
}
|
||||
} catch (bufferErr) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('Buffer conversion error:', bufferErr);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: 'Invalid file content format'});
|
||||
@@ -354,7 +358,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
writeStream.on('finish', () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
clearTimeout(commandTimeout);
|
||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
@@ -364,7 +367,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
writeStream.on('close', () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
clearTimeout(commandTimeout);
|
||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
@@ -396,7 +398,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.error('Fallback write command failed:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Write failed: ${err.message}`});
|
||||
@@ -416,7 +418,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File written successfully via fallback: ${filePath}`);
|
||||
@@ -432,7 +434,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.error('Fallback write stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Write stream error: ${streamErr.message}`});
|
||||
@@ -440,7 +442,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
});
|
||||
});
|
||||
} catch (fallbackErr) {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.error('Fallback method failed:', fallbackErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
|
||||
@@ -468,16 +470,11 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
|
||||
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH uploadFile command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
@@ -498,7 +495,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
fileBuffer = Buffer.from(content);
|
||||
}
|
||||
} catch (bufferErr) {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.error('Buffer conversion error:', bufferErr);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: 'Invalid file content format'});
|
||||
@@ -521,7 +518,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
writeStream.on('finish', () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||
@@ -531,7 +528,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
writeStream.on('close', () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||
@@ -573,7 +570,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.error('Fallback upload command failed:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Upload failed: ${err.message}`});
|
||||
@@ -593,7 +590,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
||||
@@ -609,7 +606,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.error('Fallback upload stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
|
||||
@@ -631,7 +628,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.error('Chunked fallback upload failed:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
|
||||
@@ -651,7 +648,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
||||
@@ -667,7 +664,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('Chunked fallback upload stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`});
|
||||
@@ -676,7 +672,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('Fallback method failed:', fallbackErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`});
|
||||
@@ -704,23 +699,14 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH createFile command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
sshConn.client.exec(createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH createFile error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
@@ -739,7 +725,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error(`Permission denied creating file: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
@@ -751,8 +736,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File created successfully', path: fullPath});
|
||||
@@ -774,7 +757,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH createFile stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
@@ -800,23 +782,15 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName;
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH createFolder command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
sshConn.client.exec(createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
logger.error('SSH createFolder error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
@@ -835,7 +809,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error(`Permission denied creating folder: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
@@ -847,8 +820,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'Folder created successfully', path: fullPath});
|
||||
@@ -870,7 +841,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH createFolder stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
@@ -896,24 +866,14 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH deleteItem command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const deleteCommand = isDirectory
|
||||
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
||||
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
sshConn.client.exec(deleteCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH deleteItem error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
@@ -932,7 +892,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error(`Permission denied deleting: ${itemPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
@@ -944,8 +903,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'Item deleted successfully', path: itemPath});
|
||||
@@ -967,7 +924,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH deleteItem stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
@@ -993,25 +949,16 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1);
|
||||
const newPath = oldDir + newName;
|
||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH renameItem command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
sshConn.client.exec(renameCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH renameItem error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
@@ -1030,7 +977,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error(`Permission denied renaming: ${oldPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
@@ -1042,8 +988,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'Item renamed successfully', oldPath, newPath});
|
||||
@@ -1065,7 +1009,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH renameItem stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
|
||||
@@ -115,10 +115,27 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
|
||||
(base as any).password = host.password || '';
|
||||
} else if (host.authType === 'key') {
|
||||
if (host.key) {
|
||||
(base as any).privateKey = Buffer.from(host.key, 'utf8');
|
||||
}
|
||||
if (host.keyPassword) {
|
||||
(base as any).passphrase = host.keyPassword;
|
||||
try {
|
||||
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
||||
throw new Error('Invalid private key format');
|
||||
}
|
||||
|
||||
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
|
||||
if (host.keyPassword) {
|
||||
(base as any).passphrase = host.keyPassword;
|
||||
}
|
||||
|
||||
} catch (keyError) {
|
||||
logger.error(`SSH key format error for host ${host.ip}: ${keyError.message}`);
|
||||
if (host.password) {
|
||||
(base as any).password = host.password;
|
||||
} else {
|
||||
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return base;
|
||||
@@ -278,15 +295,27 @@ async function collectMetrics(host: HostRecord): Promise<{
|
||||
let usedHuman: string | null = null;
|
||||
let totalHuman: string | null = null;
|
||||
try {
|
||||
const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
|
||||
const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 6) {
|
||||
totalHuman = parts[1] || null;
|
||||
usedHuman = parts[2] || null;
|
||||
const pctStr = (parts[4] || '').replace('%', '');
|
||||
const pctNum = Number(pctStr);
|
||||
diskPercent = Number.isFinite(pctNum) ? pctNum : null;
|
||||
// Get both human-readable and bytes format for accurate calculation
|
||||
const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
|
||||
const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
|
||||
|
||||
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||
|
||||
const humanParts = humanLine.split(/\s+/);
|
||||
const bytesParts = bytesLine.split(/\s+/);
|
||||
|
||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||
totalHuman = humanParts[1] || null;
|
||||
usedHuman = humanParts[2] || null;
|
||||
|
||||
// Calculate our own percentage using bytes for accuracy
|
||||
const totalBytes = Number(bytesParts[1]);
|
||||
const usedBytes = Number(bytesParts[2]);
|
||||
|
||||
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) {
|
||||
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
diskPercent = null;
|
||||
@@ -341,7 +370,9 @@ async function pollStatusesOnce(): Promise<void> {
|
||||
|
||||
const checks = hosts.map(async (h) => {
|
||||
const isOnline = await tcpPing(h.ip, h.port, 5000);
|
||||
hostStatuses.set(h.id, {status: isOnline ? 'online' : 'offline', lastChecked: now});
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
||||
hostStatuses.set(h.id, statusEntry);
|
||||
return isOnline;
|
||||
});
|
||||
|
||||
@@ -367,15 +398,22 @@ app.get('/status/:id', async (req, res) => {
|
||||
return res.status(400).json({error: 'Invalid id'});
|
||||
}
|
||||
|
||||
if (!hostStatuses.has(id)) {
|
||||
await pollStatusesOnce();
|
||||
try {
|
||||
const host = await fetchHostById(id);
|
||||
if (!host) {
|
||||
return res.status(404).json({error: 'Host not found'});
|
||||
}
|
||||
|
||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
||||
|
||||
hostStatuses.set(id, statusEntry);
|
||||
res.json(statusEntry);
|
||||
} catch (err) {
|
||||
logger.error('Failed to check host status', err);
|
||||
res.status(500).json({error: 'Failed to check host status'});
|
||||
}
|
||||
|
||||
const entry = hostStatuses.get(id);
|
||||
if (!entry) {
|
||||
return res.status(404).json({error: 'Host not found'});
|
||||
}
|
||||
res.json(entry);
|
||||
});
|
||||
|
||||
app.post('/refresh', async (req, res) => {
|
||||
@@ -413,9 +451,4 @@ app.listen(PORT, async () => {
|
||||
} catch (err) {
|
||||
logger.error('Initial poll failed', err);
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
|
||||
}, 60_000);
|
||||
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import chalk from 'chalk';
|
||||
|
||||
const wss = new WebSocketServer({port: 8082});
|
||||
|
||||
|
||||
|
||||
|
||||
const sshIconSymbol = '🖥️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
@@ -30,16 +33,22 @@ const logger = {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
|
||||
|
||||
ws.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on('message', (msg: RawData) => {
|
||||
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
@@ -128,38 +137,17 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 15000);
|
||||
}, 60000);
|
||||
|
||||
sshConn.on('ready', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
const pseudoTtyOpts: PseudoTtyOptions = {
|
||||
term: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
modes: {
|
||||
ECHO: 1,
|
||||
ECHOCTL: 0,
|
||||
ICANON: 1,
|
||||
ISIG: 1,
|
||||
ICRNL: 1,
|
||||
IXON: 1,
|
||||
IXOFF: 0,
|
||||
ISTRIP: 0,
|
||||
OPOST: 1,
|
||||
ONLCR: 1,
|
||||
OCRNL: 0,
|
||||
ONOCR: 0,
|
||||
ONLRET: 0,
|
||||
CS7: 0,
|
||||
CS8: 1,
|
||||
PARENB: 0,
|
||||
PARODD: 0,
|
||||
TTY_OP_ISPEED: 38400,
|
||||
TTY_OP_OSPEED: 38400,
|
||||
}
|
||||
};
|
||||
|
||||
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
|
||||
|
||||
sshConn!.shell({
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
term: 'xterm-256color'
|
||||
} as PseudoTtyOptions, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('Shell error: ' + err.message);
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
||||
@@ -168,34 +156,18 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
sshStream = stream;
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
let data: string;
|
||||
try {
|
||||
data = chunk.toString('utf8');
|
||||
} catch (e) {
|
||||
data = chunk.toString('binary');
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({type: 'data', data}));
|
||||
stream.on('data', (data: Buffer) => {
|
||||
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
cleanupSSH(connectionTimeout);
|
||||
|
||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
logger.error('SSH stream error: ' + err.message);
|
||||
|
||||
const isConnectionError = err.message.includes('ECONNRESET') ||
|
||||
err.message.includes('EPIPE') ||
|
||||
err.message.includes('ENOTCONN') ||
|
||||
err.message.includes('ETIMEDOUT');
|
||||
|
||||
if (isConnectionError) {
|
||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||
} else {
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
||||
}
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
@@ -233,18 +205,22 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
sshConn.on('close', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 10000,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
env: {
|
||||
TERM: 'xterm-256color',
|
||||
LANG: 'en_US.UTF-8',
|
||||
@@ -294,12 +270,26 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
}
|
||||
};
|
||||
if (authType === 'key' && key) {
|
||||
connectConfig.privateKey = key;
|
||||
if (keyPassword) {
|
||||
connectConfig.passphrase = keyPassword;
|
||||
}
|
||||
if (keyType && keyType !== 'auto') {
|
||||
connectConfig.privateKeyType = keyType;
|
||||
try {
|
||||
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
|
||||
throw new Error('Invalid private key format');
|
||||
}
|
||||
|
||||
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
|
||||
if (keyPassword) {
|
||||
connectConfig.passphrase = keyPassword;
|
||||
}
|
||||
|
||||
if (keyType && keyType !== 'auto') {
|
||||
connectConfig.privateKeyType = keyType;
|
||||
}
|
||||
} catch (keyError) {
|
||||
logger.error('SSH key format error: ' + keyError.message);
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
||||
return;
|
||||
}
|
||||
} else if (authType === 'key') {
|
||||
logger.error('SSH key authentication requested but no key provided');
|
||||
@@ -360,4 +350,6 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -197,7 +197,8 @@ function classifyError(errorMessage: string): ErrorType {
|
||||
|
||||
if (message.includes("connect etimedout") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("timed out")) {
|
||||
message.includes("timed out") ||
|
||||
message.includes("keepalive timeout")) {
|
||||
return ERROR_TYPES.TIMEOUT;
|
||||
}
|
||||
|
||||
@@ -267,7 +268,8 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
tunnelName,
|
||||
`${tunnelName}_confirm`,
|
||||
`${tunnelName}_retry`,
|
||||
`${tunnelName}_verify_retry`
|
||||
`${tunnelName}_verify_retry`,
|
||||
`${tunnelName}_ping`
|
||||
];
|
||||
|
||||
timerKeys.forEach(key => {
|
||||
@@ -302,7 +304,7 @@ function resetRetryState(tunnelName: string): void {
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
|
||||
['', '_confirm', '_retry', '_verify_retry', '_ping'].forEach(suffix => {
|
||||
const timerKey = `${tunnelName}${suffix}`;
|
||||
if (verificationTimers.has(timerKey)) {
|
||||
clearTimeout(verificationTimers.get(timerKey)!);
|
||||
@@ -353,7 +355,8 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
const maxRetries = tunnelConfig.maxRetries || 3;
|
||||
const retryInterval = tunnelConfig.retryInterval || 5000;
|
||||
|
||||
let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
|
||||
let retryCount = retryCounters.get(tunnelName) || 0;
|
||||
retryCount = retryCount + 1;
|
||||
|
||||
if (retryCount > maxRetries) {
|
||||
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
||||
@@ -420,7 +423,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
activeTunnels.delete(tunnelName);
|
||||
|
||||
connectSSHTunnel(tunnelConfig, retryCount);
|
||||
}
|
||||
}, retryInterval);
|
||||
@@ -438,264 +440,43 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
}
|
||||
|
||||
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
||||
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tunnelVerifications.has(tunnelName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = activeTunnels.get(tunnelName);
|
||||
if (!conn) return;
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.VERIFYING
|
||||
});
|
||||
|
||||
const verificationConn = new Client();
|
||||
tunnelVerifications.set(tunnelName, {
|
||||
conn: verificationConn,
|
||||
timeout: setTimeout(() => {
|
||||
logger.error(`Verification timeout for '${tunnelName}'`);
|
||||
cleanupVerification(false, "Verification timeout");
|
||||
}, 10000)
|
||||
});
|
||||
|
||||
function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") {
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
if (verification) {
|
||||
clearTimeout(verification.timeout);
|
||||
try {
|
||||
verification.conn.end();
|
||||
} catch (e) {
|
||||
}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
if (isSuccessful) {
|
||||
if (isPeriodic) {
|
||||
if (!activeTunnels.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: true,
|
||||
status: CONNECTION_STATES.CONNECTED
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
reason: 'Tunnel connection lost'
|
||||
});
|
||||
|
||||
if (!isPeriodic) {
|
||||
setupPingInterval(tunnelName, tunnelConfig);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
|
||||
|
||||
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: failureReason
|
||||
});
|
||||
}
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
} else {
|
||||
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
|
||||
cleanupVerification(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attemptVerification() {
|
||||
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
|
||||
|
||||
verificationConn.exec(testCmd, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error(`Verification command failed for '${tunnelName}': ${err.message}`);
|
||||
cleanupVerification(false, `Verification command failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr?.on('data', (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
cleanupVerification(true);
|
||||
} else {
|
||||
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
|
||||
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
|
||||
|
||||
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
||||
if (isTimeout) {
|
||||
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
||||
} else if (isConnectionRefused) {
|
||||
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
|
||||
}
|
||||
|
||||
cleanupVerification(false, failureReason);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
logger.error(`Verification stream error for '${tunnelName}': ${err.message}`);
|
||||
cleanupVerification(false, `Verification stream error: ${err.message}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
verificationConn.on('ready', () => {
|
||||
setTimeout(() => {
|
||||
attemptVerification();
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
verificationConn.on('error', (err: Error) => {
|
||||
cleanupVerification(false, `Verification connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
verificationConn.on('close', () => {
|
||||
if (tunnelVerifications.has(tunnelName)) {
|
||||
cleanupVerification(false, "Verification connection closed");
|
||||
}
|
||||
});
|
||||
|
||||
const connOptions: any = {
|
||||
host: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
readyTimeout: 10000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group-exchange-sha1',
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521'
|
||||
],
|
||||
cipher: [
|
||||
'aes128-ctr',
|
||||
'aes192-ctr',
|
||||
'aes256-ctr',
|
||||
'aes128-gcm@openssh.com',
|
||||
'aes256-gcm@openssh.com',
|
||||
'aes128-cbc',
|
||||
'aes192-cbc',
|
||||
'aes256-cbc',
|
||||
'3des-cbc'
|
||||
],
|
||||
hmac: [
|
||||
'hmac-sha2-256',
|
||||
'hmac-sha2-512',
|
||||
'hmac-sha1',
|
||||
'hmac-md5'
|
||||
],
|
||||
compress: [
|
||||
'none',
|
||||
'zlib@openssh.com',
|
||||
'zlib'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
||||
}
|
||||
} else if (tunnelConfig.sourceAuthMethod === "key") {
|
||||
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: "SSH key authentication requested but no key provided"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
}
|
||||
|
||||
verificationConn.connect(connOptions);
|
||||
}
|
||||
|
||||
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
||||
const pingKey = `${tunnelName}_ping`;
|
||||
if (verificationTimers.has(pingKey)) {
|
||||
clearInterval(verificationTimers.get(pingKey)!);
|
||||
verificationTimers.delete(pingKey);
|
||||
}
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
|
||||
clearInterval(pingInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = activeTunnels.get(tunnelName);
|
||||
if (!conn) {
|
||||
clearInterval(pingInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
conn.exec('echo "ping"', (err, stream) => {
|
||||
if (err) {
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
|
||||
if (!activeTunnels.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
reason: 'Tunnel connection lost'
|
||||
});
|
||||
clearInterval(pingInterval);
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.UNSTABLE,
|
||||
reason: "Ping failed"
|
||||
});
|
||||
}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
return;
|
||||
verificationTimers.delete(pingKey);
|
||||
}
|
||||
|
||||
stream.on('close', (code: number) => {
|
||||
if (code !== 0) {
|
||||
clearInterval(pingInterval);
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.UNSTABLE,
|
||||
reason: "Ping command failed"
|
||||
});
|
||||
}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
clearInterval(pingInterval);
|
||||
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.UNSTABLE,
|
||||
reason: "Ping stream error"
|
||||
});
|
||||
}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
});
|
||||
});
|
||||
}, 60000);
|
||||
} else {
|
||||
clearInterval(pingInterval);
|
||||
verificationTimers.delete(pingKey);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
verificationTimers.set(pingKey, pingInterval);
|
||||
}
|
||||
|
||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
@@ -751,7 +532,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
}, 60000);
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
@@ -778,6 +559,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
errorType === ERROR_TYPES.PORT ||
|
||||
errorType === ERROR_TYPES.PERMISSION ||
|
||||
manualDisconnects.has(tunnelName);
|
||||
|
||||
|
||||
|
||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||
});
|
||||
@@ -841,7 +624,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
|
||||
setTimeout(() => {
|
||||
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
|
||||
verifyTunnelConnection(tunnelName, tunnelConfig, false);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: true,
|
||||
status: CONNECTION_STATES.CONNECTED
|
||||
});
|
||||
setupPingInterval(tunnelName, tunnelConfig);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@@ -901,7 +688,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const errorMsg = data.toString().trim();
|
||||
logger.debug(`Tunnel stderr for '${tunnelName}': ${errorMsg}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -910,11 +696,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
host: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 10000,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
tcpKeepAliveInitialDelay: 15000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
@@ -952,8 +738,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
|
||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
@@ -962,7 +748,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
return;
|
||||
}
|
||||
|
||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
@@ -981,43 +768,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
}
|
||||
|
||||
const testSocket = new net.Socket();
|
||||
testSocket.setTimeout(5000);
|
||||
|
||||
testSocket.on('connect', () => {
|
||||
testSocket.destroy();
|
||||
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.CONNECTING,
|
||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
||||
});
|
||||
}
|
||||
|
||||
conn.connect(connOptions);
|
||||
});
|
||||
|
||||
testSocket.on('timeout', () => {
|
||||
testSocket.destroy();
|
||||
const finalStatus = connectionStatus.get(tunnelName);
|
||||
if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: "Network connectivity test failed - server not reachable"
|
||||
status: CONNECTION_STATES.CONNECTING,
|
||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
testSocket.on('error', (err: any) => {
|
||||
testSocket.destroy();
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: `Network connectivity test failed - ${err.message}`
|
||||
});
|
||||
});
|
||||
|
||||
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
||||
@@ -1029,9 +789,9 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
username: tunnelConfig.sourceUsername,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 10000,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
tcpKeepAliveInitialDelay: 15000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
@@ -1068,7 +828,13 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
}
|
||||
};
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||
callback(new Error('Invalid SSH key format'));
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,16 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import {Shield, Trash2, Users} from "lucide-react";
|
||||
import axios from "axios";
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
const API = axios.create({baseURL: apiBase});
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getUserList,
|
||||
updateRegistrationAllowed,
|
||||
updateOIDCConfig,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
@@ -67,9 +73,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
React.useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (!jwt) return;
|
||||
API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
|
||||
getOIDCConfig()
|
||||
.then(res => {
|
||||
if (res.data) setOidcConfig(res.data);
|
||||
if (res) setOidcConfig(res);
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
@@ -77,10 +83,10 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
API.get("/registration-allowed")
|
||||
getRegistrationAllowed()
|
||||
.then(res => {
|
||||
if (typeof res?.data?.allowed === 'boolean') {
|
||||
setAllowRegistration(res.data.allowed);
|
||||
if (typeof res?.allowed === 'boolean') {
|
||||
setAllowRegistration(res.allowed);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -92,8 +98,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
if (!jwt) return;
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await API.get("/list", {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
setUsers(response.data.users);
|
||||
const response = await getUserList();
|
||||
setUsers(response.users);
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
@@ -103,7 +109,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
setRegLoading(true);
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.patch("/registration-allowed", {allowed: checked}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
await updateRegistrationAllowed(checked);
|
||||
setAllowRegistration(checked);
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
@@ -126,7 +132,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/oidc-config", oidcConfig, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
setOidcSuccess("OIDC configuration updated successfully!");
|
||||
} catch (err: any) {
|
||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||
@@ -147,7 +153,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
setMakeAdminSuccess(null);
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/make-admin", {username: newAdminUsername.trim()}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
await makeUserAdmin(newAdminUsername.trim());
|
||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
@@ -162,7 +168,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
await removeAdminStatus(username);
|
||||
fetchUsers();
|
||||
} catch {
|
||||
}
|
||||
@@ -172,7 +178,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
|
||||
await deleteUser(username);
|
||||
fetchUsers();
|
||||
} catch {
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, {useState, useEffect, useRef} from "react";
|
||||
import {FileManagerLeftSidebar} from "@/ui/apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||
import {FileManagerTabList} from "@/ui/apps/File Manager/FileManagerTabList.tsx";
|
||||
import {FileManagerHomeView} from "@/ui/apps/File Manager/FileManagerHomeView.tsx";
|
||||
import {FileManagerFileEditor} from "@/ui/apps/File Manager/FileManagerFileEditor.tsx";
|
||||
import {FileManagerOperations} from "@/ui/apps/File Manager/FileManagerOperations.tsx";
|
||||
import {FileManagerLeftSidebar} from "@/ui/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||
import {FileManagerTabList} from "@/ui/Apps/File Manager/FileManagerTabList.tsx";
|
||||
import {FileManagerHomeView} from "@/ui/Apps/File Manager/FileManagerHomeView.tsx";
|
||||
import {FileManagerFileEditor} from "@/ui/Apps/File Manager/FileManagerFileEditor.tsx";
|
||||
import {FileManagerOperations} from "@/ui/Apps/File Manager/FileManagerOperations.tsx";
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
import {FIleManagerTopNavbar} from "@/ui/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {toast} from 'sonner';
|
||||
import {
|
||||
getFileManagerRecent,
|
||||
@@ -489,7 +490,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
|
||||
if (!currentHost) {
|
||||
return (
|
||||
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||
<FileManagerLeftSidebar
|
||||
onSelectView={onSelectView || (() => {
|
||||
@@ -525,7 +526,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||
<FileManagerLeftSidebar
|
||||
onSelectView={onSelectView || (() => {
|
||||
@@ -570,6 +571,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
>
|
||||
<Settings className="h-4 w-4"/>
|
||||
</Button>
|
||||
<div className="p-0.25 w-px h-[30px] bg-[#303032]"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -599,9 +601,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{activeTab === 'home' ? (
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1">
|
||||
{activeTab === 'home' ? (
|
||||
<FileManagerHomeView
|
||||
recent={recent}
|
||||
pinned={pinned}
|
||||
@@ -614,36 +616,36 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
onRemoveShortcut={handleRemoveShortcut}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
/>
|
||||
</div>
|
||||
{showOperations && (
|
||||
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
|
||||
<FileManagerOperations
|
||||
currentPath={currentPath}
|
||||
sshSessionId={currentHost?.id.toString() || null}
|
||||
onOperationComplete={handleOperationComplete}
|
||||
onError={handleError}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileManagerFileEditor
|
||||
content={tab.content}
|
||||
fileName={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileManagerFileEditor
|
||||
content={tab.content}
|
||||
fileName={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
{showOperations && (
|
||||
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
|
||||
<FileManagerOperations
|
||||
currentPath={currentPath}
|
||||
sshSessionId={currentHost?.id.toString() || null}
|
||||
onOperationComplete={handleOperationComplete}
|
||||
onError={handleError}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deletingItem && (
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useState, useRef} from 'react';
|
||||
import React, {useState, useRef, useEffect} from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
@@ -48,7 +48,29 @@ export function FileManagerOperations({
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTextLabels, setShowTextLabels] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkContainerWidth = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.offsetWidth;
|
||||
setShowTextLabels(width > 240);
|
||||
}
|
||||
};
|
||||
|
||||
checkContainerWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkContainerWidth);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!uploadFile || !sshSessionId) return;
|
||||
@@ -186,113 +208,121 @@ export function FileManagerOperations({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div ref={containerRef} className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
title="Upload File"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2"/>
|
||||
Upload File
|
||||
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">Upload File</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFile(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
title="New File"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2"/>
|
||||
New File
|
||||
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">New File</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFolder(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
title="New Folder"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-2"/>
|
||||
New Folder
|
||||
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">New Folder</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRename(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
title="Rename"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2"/>
|
||||
Rename
|
||||
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">Rename</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
||||
title="Delete Item"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2"/>
|
||||
Delete Item
|
||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">Delete Item</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Folder className="w-4 h-4 text-blue-400"/>
|
||||
<span className="text-muted-foreground">Current Path:</span>
|
||||
<span className="text-white font-mono truncate">{currentPath}</span>
|
||||
<div className="bg-[#141416] border-2 border-[#373739] rounded-md p-3">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-muted-foreground block mb-1">Current Path:</span>
|
||||
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.25 bg-[#303032]"/>
|
||||
|
||||
{showUpload && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Upload className="w-5 h-5"/>
|
||||
Upload File
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
||||
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||
<span className="break-words">Upload File</span>
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Maximum file size: 100MB (JSON) / 200MB (Binary)
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
Max: 100MB (JSON) / 200MB (Binary)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<div className="border-2 border-dashed border-[#434345] rounded-lg p-4 text-center">
|
||||
{uploadFile ? (
|
||||
<div className="space-y-2">
|
||||
<FileText className="w-8 h-8 text-blue-400 mx-auto"/>
|
||||
<p className="text-white font-medium">{uploadFile.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="space-y-3">
|
||||
<FileText className="w-12 h-12 text-blue-400 mx-auto"/>
|
||||
<p className="text-white font-medium text-sm break-words px-2">{uploadFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUploadFile(null)}
|
||||
className="mt-2"
|
||||
className="w-full text-sm h-8"
|
||||
>
|
||||
Remove File
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto"/>
|
||||
<p className="text-white">Click to select a file</p>
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-12 h-12 text-muted-foreground mx-auto"/>
|
||||
<p className="text-white text-sm break-words px-2">Click to select a file</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openFileDialog}
|
||||
className="w-full text-sm h-8"
|
||||
>
|
||||
Choose File
|
||||
</Button>
|
||||
@@ -308,11 +338,11 @@ export function FileManagerOperations({
|
||||
accept="*/*"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleFileUpload}
|
||||
disabled={!uploadFile || isLoading}
|
||||
className="flex-1"
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Uploading...' : 'Upload File'}
|
||||
</Button>
|
||||
@@ -320,6 +350,7 @@ export function FileManagerOperations({
|
||||
variant="outline"
|
||||
onClick={() => setShowUpload(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -329,23 +360,25 @@ export function FileManagerOperations({
|
||||
)}
|
||||
|
||||
{showCreateFile && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FilePlus className="w-5 h-5"/>
|
||||
Create New File
|
||||
</h3>
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||
<span className="break-words">Create New File</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFile(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
File Name
|
||||
@@ -354,16 +387,16 @@ export function FileManagerOperations({
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder="Enter file name (e.g., example.txt)"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleCreateFile}
|
||||
disabled={!newFileName.trim() || isLoading}
|
||||
className="flex-1"
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create File'}
|
||||
</Button>
|
||||
@@ -371,6 +404,7 @@ export function FileManagerOperations({
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateFile(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -380,23 +414,25 @@ export function FileManagerOperations({
|
||||
)}
|
||||
|
||||
{showCreateFolder && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FolderPlus className="w-5 h-5"/>
|
||||
Create New Folder
|
||||
</h3>
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<FolderPlus className="w-6 h-6 flex-shrink-0"/>
|
||||
<span className="break-words">Create New Folder</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
Folder Name
|
||||
@@ -405,16 +441,16 @@ export function FileManagerOperations({
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Enter folder name"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleCreateFolder}
|
||||
disabled={!newFolderName.trim() || isLoading}
|
||||
className="flex-1"
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Folder'}
|
||||
</Button>
|
||||
@@ -422,6 +458,7 @@ export function FileManagerOperations({
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -431,27 +468,29 @@ export function FileManagerOperations({
|
||||
)}
|
||||
|
||||
{showDelete && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5 text-red-400"/>
|
||||
Delete Item
|
||||
</h3>
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0"/>
|
||||
<span className="break-words">Delete Item</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDelete(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-red-300">
|
||||
<AlertCircle className="w-4 h-4"/>
|
||||
<span className="text-sm font-medium">Warning: This action cannot be undone</span>
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
|
||||
<span className="text-sm font-medium break-words">Warning: This action cannot be undone</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -462,30 +501,30 @@ export function FileManagerOperations({
|
||||
<Input
|
||||
value={deletePath}
|
||||
onChange={(e) => setDeletePath(e.target.value)}
|
||||
placeholder="Enter full path to item (e.g., /path/to/file.txt)"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||
placeholder="Enter full path to item"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="deleteIsDirectory"
|
||||
checked={deleteIsDirectory}
|
||||
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
||||
className="rounded border-[#434345] bg-[#23232a]"
|
||||
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<label htmlFor="deleteIsDirectory" className="text-sm text-white">
|
||||
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words">
|
||||
This is a directory (will delete recursively)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!deletePath || isLoading}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Item'}
|
||||
</Button>
|
||||
@@ -493,6 +532,7 @@ export function FileManagerOperations({
|
||||
variant="outline"
|
||||
onClick={() => setShowDelete(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -502,23 +542,25 @@ export function FileManagerOperations({
|
||||
)}
|
||||
|
||||
{showRename && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Edit3 className="w-5 h-5"/>
|
||||
Rename Item
|
||||
</h3>
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Edit3 className="w-6 h-6 flex-shrink-0"/>
|
||||
<span className="break-words">Rename Item</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowRename(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
Current Path
|
||||
@@ -527,7 +569,7 @@ export function FileManagerOperations({
|
||||
value={renamePath}
|
||||
onChange={(e) => setRenamePath(e.target.value)}
|
||||
placeholder="Enter current path to item"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -539,29 +581,29 @@ export function FileManagerOperations({
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Enter new name"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="renameIsDirectory"
|
||||
checked={renameIsDirectory}
|
||||
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
||||
className="rounded border-[#434345] bg-[#23232a]"
|
||||
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<label htmlFor="renameIsDirectory" className="text-sm text-white">
|
||||
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words">
|
||||
This is a directory
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleRename}
|
||||
disabled={!renamePath || !newName.trim() || isLoading}
|
||||
className="flex-1"
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Renaming...' : 'Rename Item'}
|
||||
</Button>
|
||||
@@ -569,6 +611,7 @@ export function FileManagerOperations({
|
||||
variant="outline"
|
||||
onClick={() => setShowRename(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -21,7 +21,7 @@ export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onH
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="outline"
|
||||
className={`h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
<Home className="w-4 h-4"/>
|
||||
</Button>
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, {useState} from "react";
|
||||
import {HostManagerHostViewer} from "@/ui/apps/Host Manager/HostManagerHostViewer.tsx"
|
||||
import {HostManagerHostViewer} from "@/ui/Apps/Host Manager/HostManagerHostViewer.tsx"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {HostManagerHostEditor} from "@/ui/apps/Host Manager/HostManagerHostEditor.tsx";
|
||||
import {HostManagerHostEditor} from "@/ui/Apps/Host Manager/HostManagerHostEditor.tsx";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
|
||||
interface HostManagerProps {
|
||||
@@ -582,11 +582,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="password" {...field} />
|
||||
<Input type="password" placeholder="password" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -325,257 +325,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const infoContent = `
|
||||
JSON Import Format Guide
|
||||
|
||||
REQUIRED FIELDS:
|
||||
• ip: Host IP address (string)
|
||||
• port: SSH port (number, 1-65535)
|
||||
• username: SSH username (string)
|
||||
• authType: "password" or "key"
|
||||
|
||||
AUTHENTICATION FIELDS:
|
||||
• password: Required if authType is "password"
|
||||
• key: SSH private key content (string) if authType is "key"
|
||||
• keyPassword: Optional key passphrase
|
||||
• keyType: Key type (auto, ssh-rsa, ssh-ed25519, etc.)
|
||||
|
||||
OPTIONAL FIELDS:
|
||||
• name: Display name (string)
|
||||
• folder: Organization folder (string)
|
||||
• tags: Array of tag strings
|
||||
• pin: Pin to top (boolean)
|
||||
• enableTerminal: Show in Terminal tab (boolean, default: true)
|
||||
• enableTunnel: Show in Tunnel tab (boolean, default: true)
|
||||
• enableFileManager: Show in File Manager tab (boolean, default: true)
|
||||
• defaultPath: Default directory path (string)
|
||||
|
||||
TUNNEL CONFIGURATION:
|
||||
• tunnelConnections: Array of tunnel objects
|
||||
- sourcePort: Local port (number)
|
||||
- endpointPort: Remote port (number)
|
||||
- endpointHost: Target host name (string)
|
||||
- maxRetries: Retry attempts (number, default: 3)
|
||||
- retryInterval: Retry delay in seconds (number, default: 10)
|
||||
- autoStart: Auto-start on launch (boolean, default: false)
|
||||
|
||||
EXAMPLE STRUCTURE:
|
||||
{
|
||||
"hosts": [
|
||||
{
|
||||
"name": "Web Server",
|
||||
"ip": "192.168.1.100",
|
||||
"port": 22,
|
||||
"username": "admin",
|
||||
"authType": "password",
|
||||
"password": "your_password",
|
||||
"folder": "Production",
|
||||
"tags": ["web", "production"],
|
||||
"pin": true,
|
||||
"enableTerminal": true,
|
||||
"enableTunnel": false,
|
||||
"enableFileManager": true,
|
||||
"defaultPath": "/var/www"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
• Maximum 100 hosts per import
|
||||
• File should contain a "hosts" array or be an array of host objects
|
||||
• All fields are copyable for easy reference
|
||||
`;
|
||||
|
||||
const newWindow = window.open('', '_blank', 'width=600,height=800,scrollbars=yes,resizable=yes');
|
||||
if (newWindow) {
|
||||
newWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SSH JSON Import Guide</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 20px;
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
line-height: 1.6;
|
||||
}
|
||||
pre {
|
||||
background: #2a2a2a;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
code {
|
||||
background: #404040;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
h1 { color: #60a5fa; border-bottom: 2px solid #60a5fa; padding-bottom: 10px; }
|
||||
h2 { color: #34d399; margin-top: 25px; }
|
||||
.field-group { margin: 15px 0; }
|
||||
.field-item { margin: 8px 0; }
|
||||
.copy-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.copy-btn:hover { background: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SSH JSON Import Format Guide</h1>
|
||||
<p>Use this guide to create JSON files for bulk importing SSH hosts. All examples are copyable.</p>
|
||||
|
||||
<h2>Required Fields</h2>
|
||||
<div class="field-group">
|
||||
<div class="field-item">
|
||||
<code>ip</code> - Host IP address (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('ip')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>port</code> - SSH port (number, 1-65535)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('port')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>username</code> - SSH username (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('username')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>authType</code> - "password" or "key"
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('authType')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Authentication Fields</h2>
|
||||
<div class="field-group">
|
||||
<div class="field-item">
|
||||
<code>password</code> - Required if authType is "password"
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('password')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>key</code> - SSH private key content (string) if authType is "key"
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('key')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>keyPassword</code> - Optional key passphrase
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('keyPassword')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>keyType</code> - Key type (auto, ssh-rsa, ssh-ed25519, etc.)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('keyType')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Optional Fields</h2>
|
||||
<div class="field-group">
|
||||
<div class="field-item">
|
||||
<code>name</code> - Display name (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('name')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>folder</code> - Organization folder (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('folder')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>tags</code> - Array of tag strings
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('tags')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>pin</code> - Pin to top (boolean)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('pin')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>enableTerminal</code> - Show in Terminal tab (boolean, default: true)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableTerminal')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>enableTunnel</code> - Show in Tunnel tab (boolean, default: true)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableTunnel')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>enableFileManager</code> - Show in File Manager tab (boolean, default: true)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableFileManager')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>defaultPath</code> - Default directory path (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('defaultPath')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Tunnel Configuration</h2>
|
||||
<div class="field-group">
|
||||
<div class="field-item">
|
||||
<code>tunnelConnections</code> - Array of tunnel objects
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('tunnelConnections')">Copy</button>
|
||||
</div>
|
||||
<div style="margin-left: 20px;">
|
||||
<div class="field-item">
|
||||
<code>sourcePort</code> - Local port (number)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('sourcePort')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>endpointPort</code> - Remote port (number)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('endpointPort')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>endpointHost</code> - Target host name (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('endpointHost')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>maxRetries</code> - Retry attempts (number, default: 3)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('maxRetries')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>retryInterval</code> - Retry delay in seconds (number, default: 10)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('retryInterval')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>autoStart</code> - Auto-start on launch (boolean, default: false)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('autoStart')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Example JSON Structure</h2>
|
||||
<pre><code>{
|
||||
"hosts": [
|
||||
{
|
||||
"name": "Web Server",
|
||||
"ip": "192.168.1.100",
|
||||
"port": 22,
|
||||
"username": "admin",
|
||||
"authType": "password",
|
||||
"password": "your_password",
|
||||
"folder": "Production",
|
||||
"tags": ["web", "production"],
|
||||
"pin": true,
|
||||
"enableTerminal": true,
|
||||
"enableTunnel": false,
|
||||
"enableFileManager": true,
|
||||
"defaultPath": "/var/www"
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
|
||||
<h2>Important Notes</h2>
|
||||
<ul>
|
||||
<li>Maximum 100 hosts per import</li>
|
||||
<li>File should contain a "hosts" array or be an array of host objects</li>
|
||||
<li>All fields are copyable for easy reference</li>
|
||||
<li>Use the Download Sample button to get a complete example file</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
newWindow.document.close();
|
||||
}
|
||||
window.open('https://docs.termix.site/json-import', '_blank');
|
||||
}}
|
||||
>
|
||||
Format Guide
|
||||
@@ -677,7 +427,7 @@ EXAMPLE STRUCTURE:
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="secondary"
|
||||
<Badge key={index} variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
@@ -5,8 +5,8 @@ import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||
import {Tunnel} from "@/ui/apps/Tunnel/Tunnel.tsx";
|
||||
import {getServerStatusById, getServerMetricsById, ServerMetrics} from "@/ui/main-axios.ts";
|
||||
import {Tunnel} from "@/ui/Apps/Tunnel/Tunnel.tsx";
|
||||
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
|
||||
interface ServerProps {
|
||||
@@ -25,7 +25,7 @@ export function Server({
|
||||
embedded = false
|
||||
}: ServerProps): React.ReactElement {
|
||||
const {state: sidebarState} = useSidebar();
|
||||
const {addTab} = useTabs() as any;
|
||||
const {addTab, tabs} = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
@@ -94,25 +94,35 @@ export function Server({
|
||||
}
|
||||
};
|
||||
|
||||
if (currentHostConfig?.id) {
|
||||
if (currentHostConfig?.id && isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}, 10_000);
|
||||
if (isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id]);
|
||||
}, [currentHostConfig?.id, isVisible]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||
if (!currentHostConfig) return false;
|
||||
return tabs.some((tab: any) =>
|
||||
tab.type === 'file_manager' &&
|
||||
tab.hostConfig?.id === currentHostConfig.id
|
||||
);
|
||||
}, [tabs, currentHostConfig]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
||||
: {
|
||||
@@ -142,13 +152,34 @@ export function Server({
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
setMetrics(data);
|
||||
} catch {
|
||||
setServerStatus('offline');
|
||||
setMetrics(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Refresh status and metrics"
|
||||
>
|
||||
Refresh Status
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={isFileManagerAlreadyOpen ? "File Manager already open for this host" : "Open File Manager"}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig) return;
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
@@ -210,7 +241,7 @@ export function Server({
|
||||
|
||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||
|
||||
{/* HDD */}
|
||||
{/* Root Storage */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
||||
<HardDrive/>
|
||||
@@ -221,7 +252,7 @@ export function Server({
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = used ?? 'N/A';
|
||||
const totalText = total ?? 'N/A';
|
||||
return `HDD Space - ${pctText} (${usedText} of ${totalText})`;
|
||||
return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface SSHTerminalProps {
|
||||
splitScreen?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{hostConfig, isVisible, splitScreen = false},
|
||||
ref
|
||||
) {
|
||||
@@ -26,6 +26,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
const [visible, setVisible] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -115,6 +116,50 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
return getCookie("rightClickCopyPaste") === "true"
|
||||
}
|
||||
|
||||
|
||||
|
||||
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||
ws.addEventListener('open', () => {
|
||||
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({type: 'input', data}));
|
||||
});
|
||||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({type: 'ping'}));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') terminal.write(msg.data);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
else if (msg.type === 'connected') {
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln('\r\n[Connection closed]');
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
@@ -222,48 +267,26 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
setVisible(true);
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
const wsUrl = window.location.hostname === 'localhost' ? 'ws://localhost:8082' : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8082'
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({type: 'input', data}));
|
||||
});
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({type: 'ping'}));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') terminal.write(msg.data);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
else if (msg.type === 'connected') {
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]');
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
});
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
@@ -286,9 +309,18 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [isVisible]);
|
||||
}, [isVisible, splitScreen, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
@@ -296,12 +328,23 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen && isVisible) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
}, [splitScreen]);
|
||||
}, [splitScreen, isVisible, terminal]);
|
||||
|
||||
return (
|
||||
<div ref={xtermRef} className="h-full w-full m-1"
|
||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}/>
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full m-1"
|
||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useState, useEffect, useCallback} from "react";
|
||||
import {TunnelViewer} from "@/ui/apps/Tunnel/TunnelViewer.tsx";
|
||||
import {TunnelViewer} from "@/ui/Apps/Tunnel/TunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
||||
|
||||
interface TunnelConnection {
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
|
||||
import axios from "axios";
|
||||
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
|
||||
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
|
||||
|
||||
interface HomepageProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -25,12 +25,6 @@ function setCookie(name: string, value: string, days = 7) {
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function Homepage({
|
||||
onSelectView,
|
||||
isAuthenticated,
|
||||
@@ -53,13 +47,13 @@ export function Homepage({
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
Promise.all([
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
|
||||
API.get("/db-health")
|
||||
getUserInfo(),
|
||||
getDatabaseHealth()
|
||||
])
|
||||
.then(([meRes]) => {
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setUserId(meRes.data.userId || null);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -78,76 +72,82 @@ export function Homepage({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-svh grid place-items-center relative transition-[padding-top] duration-200 ease-linear ${
|
||||
className={`w-full min-h-svh relative transition-[padding-top] duration-200 ease-linear ${
|
||||
isTopbarOpen ? 'pt-[66px]' : 'pt-2'
|
||||
}`}>
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
dbError={dbError}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center justify-center gap-8">
|
||||
{loggedIn && (
|
||||
<div className="flex flex-col items-center gap-4 w-[350px]">
|
||||
{!loggedIn ? (
|
||||
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
dbError={dbError}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
|
||||
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||
<div
|
||||
className="my-2 text-center bg-muted/50 border-2 border-[#303032] rounded-lg p-4 w-full">
|
||||
<h3 className="text-lg font-semibold mb-2">Logged in!</h3>
|
||||
<p className="text-muted-foreground">
|
||||
className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">
|
||||
<h3 className="text-xl font-bold mb-3 text-white">Logged in!</h3>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
You are logged in! Use the sidebar to access all available tools. To get started,
|
||||
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
|
||||
host using the other apps in the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<div className="w-px h-4 bg-[#303032]"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<div className="w-px h-4 bg-[#303032]"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<div className="w-px h-4 bg-[#303032]"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||
>
|
||||
Donate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HomepageUpdateLog
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
<HomepageUpdateLog
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HomepageAlertManager
|
||||
userId={userId}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import axios from "axios";
|
||||
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||
|
||||
interface TermixAlert {
|
||||
id: string;
|
||||
@@ -19,12 +19,6 @@ interface AlertManagerProps {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/alerts" : "/alerts";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
|
||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||
@@ -44,9 +38,9 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await API.get(`/user/${userId}`);
|
||||
const response = await getUserAlerts(userId);
|
||||
|
||||
const userAlerts = response.data.alerts || [];
|
||||
const userAlerts = response.alerts || [];
|
||||
|
||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
|
||||
@@ -73,10 +67,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const response = await API.post('/dismiss', {
|
||||
userId,
|
||||
alertId
|
||||
});
|
||||
await dismissAlert(userId, alertId);
|
||||
|
||||
setAlerts(prev => {
|
||||
const newAlerts = prev.filter(alert => alert.id !== alertId);
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import {cn} from "@/lib/utils.ts";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import axios from "axios";
|
||||
import {cn} from "../../lib/utils.ts";
|
||||
import {Button} from "../../components/ui/button.tsx";
|
||||
import {Input} from "../../components/ui/input.tsx";
|
||||
import {Label} from "../../components/ui/label.tsx";
|
||||
import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
getUserInfo,
|
||||
getRegistrationAllowed,
|
||||
getOIDCConfig,
|
||||
getUserCount,
|
||||
initiatePasswordReset,
|
||||
verifyPasswordResetCode,
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin
|
||||
} from "../main-axios.ts";
|
||||
|
||||
function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
@@ -18,12 +30,6 @@ function getCookie(name: string) {
|
||||
}, "");
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||
setLoggedIn: (loggedIn: boolean) => void;
|
||||
setIsAdmin: (isAdmin: boolean) => void;
|
||||
@@ -68,20 +74,25 @@ export function HomepageAuth({
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetSuccess, setResetSuccess] = useState(false);
|
||||
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpTempToken, setTotpTempToken] = useState("");
|
||||
const [totpLoading, setTotpLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalLoggedIn(loggedIn);
|
||||
}, [loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
API.get("/registration-allowed").then(res => {
|
||||
setRegistrationAllowed(res.data.allowed);
|
||||
getRegistrationAllowed().then(res => {
|
||||
setRegistrationAllowed(res.allowed);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
API.get("/oidc-config").then((response) => {
|
||||
if (response.data) {
|
||||
getOIDCConfig().then((response) => {
|
||||
if (response) {
|
||||
setOidcConfigured(true);
|
||||
} else {
|
||||
setOidcConfigured(false);
|
||||
@@ -96,8 +107,8 @@ export function HomepageAuth({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
API.get("/count").then(res => {
|
||||
if (res.data.count === 0) {
|
||||
getUserCount().then(res => {
|
||||
if (res.count === 0) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
} else {
|
||||
@@ -123,7 +134,7 @@ export function HomepageAuth({
|
||||
try {
|
||||
let res, meRes;
|
||||
if (tab === "login") {
|
||||
res = await API.post("/login", {username: localUsername, password});
|
||||
res = await loginUser(localUsername, password);
|
||||
} else {
|
||||
if (password !== signupConfirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
@@ -135,31 +146,47 @@ export function HomepageAuth({
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await API.post("/create", {username: localUsername, password});
|
||||
res = await API.post("/login", {username: localUsername, password});
|
||||
|
||||
await registerUser(localUsername, password);
|
||||
res = await loginUser(localUsername, password);
|
||||
}
|
||||
setCookie("jwt", res.data.token);
|
||||
|
||||
if (res.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpTempToken(res.temp_token);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error('No token received from login');
|
||||
}
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
[meRes] = await Promise.all([
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}),
|
||||
API.get("/db-health")
|
||||
getUserInfo(),
|
||||
]);
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setUserId(meRes.data.id || null);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.data.is_admin,
|
||||
username: meRes.data.username || null,
|
||||
userId: meRes.data.id || null
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
if (tab === "signup") {
|
||||
setSignupConfirmPassword("");
|
||||
}
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Unknown error");
|
||||
setError(err?.response?.data?.error || err?.message || "Unknown error");
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
@@ -176,29 +203,26 @@ export function HomepageAuth({
|
||||
}
|
||||
}
|
||||
|
||||
async function initiatePasswordReset() {
|
||||
async function handleInitiatePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
await API.post("/initiate-reset", {username: localUsername});
|
||||
const result = await initiatePasswordReset(localUsername);
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to initiate password reset");
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyResetCode() {
|
||||
async function handleVerifyResetCode() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const response = await API.post("/verify-reset-code", {
|
||||
username: localUsername,
|
||||
resetCode: resetCode
|
||||
});
|
||||
setTempToken(response.data.tempToken);
|
||||
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
||||
setTempToken(response.tempToken);
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
@@ -208,7 +232,7 @@ export function HomepageAuth({
|
||||
}
|
||||
}
|
||||
|
||||
async function completePasswordReset() {
|
||||
async function handleCompletePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
|
||||
@@ -225,11 +249,7 @@ export function HomepageAuth({
|
||||
}
|
||||
|
||||
try {
|
||||
await API.post("/complete-reset", {
|
||||
username: localUsername,
|
||||
tempToken: tempToken,
|
||||
newPassword: newPassword
|
||||
});
|
||||
await completePasswordReset(localUsername, tempToken, newPassword);
|
||||
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
@@ -263,12 +283,53 @@ export function HomepageAuth({
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleTOTPVerification() {
|
||||
if (totpCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setTotpLoading(true);
|
||||
|
||||
try {
|
||||
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error('No token received from TOTP verification');
|
||||
}
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
const meRes = await getUserInfo();
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Invalid TOTP code");
|
||||
} finally {
|
||||
setTotpLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOIDCLogin() {
|
||||
setError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const authResponse = await API.get("/oidc/authorize");
|
||||
const {auth_url: authUrl} = authResponse.data;
|
||||
const authResponse = await getOIDCAuthorizeUrl();
|
||||
const {auth_url: authUrl} = authResponse;
|
||||
|
||||
if (!authUrl || authUrl === 'undefined') {
|
||||
throw new Error('Invalid authorization URL received from backend');
|
||||
@@ -299,18 +360,18 @@ export function HomepageAuth({
|
||||
setError(null);
|
||||
|
||||
setCookie("jwt", token);
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${token}`}})
|
||||
getUserInfo()
|
||||
.then(meRes => {
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setUserId(meRes.data.id || null);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.id || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.data.is_admin,
|
||||
username: meRes.data.username || null,
|
||||
userId: meRes.data.id || null
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.id || null
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
@@ -340,7 +401,7 @@ export function HomepageAuth({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col ${className || ''}`}
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-[#18181b] border-2 border-[#303032] rounded-md ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{dbError && (
|
||||
@@ -375,7 +436,58 @@ export function HomepageAuth({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
|
||||
{totpRequired && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">Two-Factor Authentication</h2>
|
||||
<p className="text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="totp-code">Authentication Code</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={totpLoading}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Or enter a backup code if you don't have access to your authenticator
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={totpLoading || totpCode.length < 6}
|
||||
onClick={handleTOTPVerification}
|
||||
>
|
||||
{totpLoading ? Spinner : "Verify"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={totpLoading}
|
||||
onClick={() => {
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
@@ -486,7 +598,7 @@ export function HomepageAuth({
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={initiatePasswordReset}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
</Button>
|
||||
@@ -495,7 +607,7 @@ export function HomepageAuth({
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<>o
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
@@ -519,7 +631,7 @@ export function HomepageAuth({
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={verifyResetCode}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
</Button>
|
||||
@@ -598,7 +710,7 @@ export function HomepageAuth({
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={completePasswordReset}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
</Button>
|
||||
@@ -680,4 +792,4 @@ export function HomepageAuth({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import axios from "axios";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||
|
||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
@@ -50,12 +50,6 @@ interface VersionResponse {
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081" : "";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||
@@ -66,12 +60,12 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
if (loggedIn) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
API.get('/releases/rss?per_page=100'),
|
||||
API.get('/version/')
|
||||
getReleasesRSS(100),
|
||||
getVersionInfo()
|
||||
])
|
||||
.then(([releasesRes, versionRes]) => {
|
||||
setReleases(releasesRes.data);
|
||||
setVersionInfo(versionRes.data);
|
||||
setReleases(releasesRes);
|
||||
setVersionInfo(versionRes);
|
||||
setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -90,75 +84,67 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
return firstLine
|
||||
.replace(/[#*`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.substring(0, 100) + (firstLine.length > 100 ? '...' : '');
|
||||
.trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[400px] h-[600px] flex flex-col border-2 border-border rounded-lg bg-card p-4">
|
||||
<div className="w-[400px] h-[600px] flex flex-col border-2 border-[#303032] rounded-lg bg-[#18181b] p-4 shadow-lg">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Updates & Releases</h3>
|
||||
<h3 className="text-lg font-bold mb-3 text-white">Updates & Releases</h3>
|
||||
|
||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
||||
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
|
||||
|
||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||
<Alert>
|
||||
<AlertTitle>Update Available</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
|
||||
<AlertTitle className="text-white">Update Available</AlertTitle>
|
||||
<AlertDescription className="text-gray-300">
|
||||
A new version ({versionInfo.version}) is available.
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto underline ml-1"
|
||||
onClick={() => window.open("https://docs.termix.site/docs", '_blank')}
|
||||
>
|
||||
Update now
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
||||
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-3">
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
|
||||
<AlertTitle className="text-red-300">Error</AlertTitle>
|
||||
<AlertDescription className="text-red-300">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{releases?.items.map((release) => (
|
||||
<div
|
||||
key={release.id}
|
||||
className="border border-border rounded-lg p-3 hover:bg-accent transition-colors cursor-pointer"
|
||||
className="border border-[#303032] rounded-lg p-3 hover:bg-[#0e0e10] transition-colors cursor-pointer bg-[#0e0e10]/50"
|
||||
onClick={() => window.open(release.link, '_blank')}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-medium text-sm leading-tight flex-1">
|
||||
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
|
||||
{release.title}
|
||||
</h4>
|
||||
{release.isPrerelease && (
|
||||
<span
|
||||
className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded ml-2 flex-shrink-0">
|
||||
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||
Pre-release
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mb-2 leading-relaxed">
|
||||
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
|
||||
{formatDescription(release.description)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<div className="flex items-center text-xs text-gray-400">
|
||||
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
|
||||
{release.assets.length > 0 && (
|
||||
<>
|
||||
@@ -171,9 +157,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
))}
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert>
|
||||
<AlertTitle>No Releases</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
|
||||
<AlertTitle className="text-gray-300">No Releases</AlertTitle>
|
||||
<AlertDescription className="text-gray-400">
|
||||
No releases found.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx";
|
||||
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
|
||||
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
|
||||
import {Terminal} from "@/ui/Apps/Terminal/Terminal.tsx";
|
||||
import {Server as ServerView} from "@/ui/Apps/Server/Server.tsx";
|
||||
import {FileManager} from "@/ui/Apps/File Manager/FileManager.tsx";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
@@ -108,12 +108,13 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||
|
||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||
const isFileManagerTab = mainTab.type === 'file_manager';
|
||||
styles[mainTab.id] = {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
top: isFileManagerTab ? 0 : 2,
|
||||
left: isFileManagerTab ? 0 : 2,
|
||||
right: isFileManagerTab ? 0 : 2,
|
||||
bottom: isFileManagerTab ? 0 : 2,
|
||||
zIndex: 20,
|
||||
display: 'block',
|
||||
pointerEvents: 'auto',
|
||||
@@ -154,9 +155,9 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
const effectiveVisible = isVisible && ready;
|
||||
return (
|
||||
<div key={t.id} style={finalStyle}>
|
||||
<div className="absolute inset-0 rounded-md" style={{background: '#18181b'}}>
|
||||
<div className="absolute inset-0 rounded-md bg-[#18181b]">
|
||||
{t.type === 'terminal' ? (
|
||||
<TerminalComponent
|
||||
<Terminal
|
||||
ref={t.terminalRef}
|
||||
hostConfig={t.hostConfig}
|
||||
isVisible={effectiveVisible}
|
||||
@@ -523,6 +524,10 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
|
||||
const isFileManager = currentTabData?.type === 'file_manager';
|
||||
const isSplitScreen = allSplitScreenTab.length > 0;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
@@ -533,7 +538,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
||||
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: '#18181b',
|
||||
background: (isFileManager && !isSplitScreen) ? '#09090b' : '#18181b',
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
|
||||
@@ -35,15 +35,15 @@ interface HostProps {
|
||||
|
||||
export function Host({host}: HostProps): React.ReactElement {
|
||||
const {addTab} = useTabs();
|
||||
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
|
||||
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded');
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
@@ -57,7 +57,8 @@ export function Host({host}: HostProps): React.ReactElement {
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
intervalId = window.setInterval(fetchStatus, 60_000);
|
||||
|
||||
intervalId = window.setInterval(fetchStatus, 10000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -45,11 +45,18 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import axios from "axios";
|
||||
import {Card} from "@/components/ui/card.tsx";
|
||||
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getUserList,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser,
|
||||
deleteAccount
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -95,11 +102,7 @@ function getCookie(name: string) {
|
||||
}, "");
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function LeftSidebar({
|
||||
onSelectView,
|
||||
@@ -162,9 +165,9 @@ export function LeftSidebar({
|
||||
if (adminSheetOpen) {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt && isAdmin) {
|
||||
API.get("/oidc-config").then(res => {
|
||||
if (res.data) {
|
||||
setOidcConfig(res.data);
|
||||
getOIDCConfig().then(res => {
|
||||
if (res) {
|
||||
setOidcConfig(res);
|
||||
}
|
||||
}).catch((error) => {
|
||||
});
|
||||
@@ -235,7 +238,7 @@ export function LeftSidebar({
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 10000);
|
||||
const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHosts]);
|
||||
|
||||
@@ -308,10 +311,7 @@ export function LeftSidebar({
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.delete("/delete-account", {
|
||||
headers: {Authorization: `Bearer ${jwt}`},
|
||||
data: {password: deletePassword}
|
||||
});
|
||||
await deleteAccount(deletePassword);
|
||||
|
||||
handleLogout();
|
||||
} catch (err: any) {
|
||||
@@ -329,12 +329,10 @@ export function LeftSidebar({
|
||||
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await API.get("/list", {
|
||||
headers: {Authorization: `Bearer ${jwt}`}
|
||||
});
|
||||
setUsers(response.data.users);
|
||||
const response = await getUserList();
|
||||
setUsers(response.users);
|
||||
|
||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
||||
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
} finally {
|
||||
@@ -350,10 +348,8 @@ export function LeftSidebar({
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.get("/list", {
|
||||
headers: {Authorization: `Bearer ${jwt}`}
|
||||
});
|
||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
||||
const response = await getUserList();
|
||||
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
}
|
||||
@@ -373,10 +369,7 @@ export function LeftSidebar({
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/make-admin",
|
||||
{username: newAdminUsername.trim()},
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
await makeUserAdmin(newAdminUsername.trim());
|
||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
@@ -396,10 +389,7 @@ export function LeftSidebar({
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/remove-admin",
|
||||
{username},
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
await removeAdminStatus(username);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
}
|
||||
@@ -414,10 +404,7 @@ export function LeftSidebar({
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.delete("/delete-user", {
|
||||
headers: {Authorization: `Bearer ${jwt}`},
|
||||
data: {username}
|
||||
});
|
||||
await deleteUser(username);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
}
|
||||
@@ -442,7 +429,7 @@ export function LeftSidebar({
|
||||
<Separator className="p-0.25"/>
|
||||
<SidebarContent>
|
||||
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
||||
<Button className="m-2 flex flex-row font-semibold" variant="outline"
|
||||
<Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline"
|
||||
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
|
||||
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
|
||||
<HardDrive strokeWidth="2.5"/>
|
||||
@@ -451,12 +438,12 @@ export function LeftSidebar({
|
||||
</SidebarGroup>
|
||||
<Separator className="p-0.25"/>
|
||||
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
|
||||
<div className="bg-[#131316] rounded-lg">
|
||||
<div className="!bg-[#222225] rounded-lg">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search hosts by any info..."
|
||||
className="w-full h-8 text-sm border-2 border-[#272728] rounded-lg"
|
||||
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -510,6 +497,20 @@ export function LeftSidebar({
|
||||
sideOffset={6}
|
||||
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={() => {
|
||||
if (isSplitScreenActive) return;
|
||||
const profileTab = tabList.find((t: any) => t.type === 'profile');
|
||||
if (profileTab) {
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({type: 'profile', title: 'Profile'} as any);
|
||||
setCurrentTab(id);
|
||||
}}>
|
||||
<span>Profile & Security</span>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
|
||||
@@ -50,7 +50,7 @@ export function TabProvider({children}: TabProviderProps) {
|
||||
const usedNumbers = new Set<number>();
|
||||
let rootUsed = false;
|
||||
tabs.forEach(t => {
|
||||
if (t.type !== tabType || !t.title) return;
|
||||
if (!t.title) return;
|
||||
if (t.title === root) {
|
||||
rootUsed = true;
|
||||
return;
|
||||
|
||||
262
src/ui/User/PasswordReset.tsx
Normal file
262
src/ui/User/PasswordReset.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Key} from "lucide-react";
|
||||
import React, {useState} from "react";
|
||||
import {completePasswordReset, initiatePasswordReset, verifyPasswordResetCode} from "@/ui/main-axios.ts";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
|
||||
interface PasswordResetProps {
|
||||
userInfo: {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
totp_enabled: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
|
||||
const [resetCode, setResetCode] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetSuccess, setResetSuccess] = useState(false);
|
||||
|
||||
async function handleInitiatePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const result = await initiatePasswordReset(userInfo.username);
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPasswordState() {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
setResetSuccess(false);
|
||||
}
|
||||
|
||||
async function handleVerifyResetCode() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const response = await verifyPasswordResetCode(userInfo.username, resetCode);
|
||||
setTempToken(response.tempToken);
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to verify reset code");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompletePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await completePasswordReset(userInfo.username, tempToken, newPassword);
|
||||
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
|
||||
setResetSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to complete password reset");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const Spinner = (
|
||||
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5"/>
|
||||
Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Change your account password
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<>
|
||||
{resetStep === "initiate" && !resetSuccess && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !userInfo.username.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{userInfo.username}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">Reset Code</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetSuccess && (
|
||||
<>
|
||||
<Alert className="">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your password has been successfully reset! You can now log in
|
||||
with your new password.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{userInfo.username}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
437
src/ui/User/TOTPSetup.tsx
Normal file
437
src/ui/User/TOTPSetup.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
|
||||
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface TOTPSetupProps {
|
||||
isEnabled: boolean;
|
||||
onStatusChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) {
|
||||
const [isEnabled, setIsEnabled] = useState(initialEnabled);
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init");
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [disableCode, setDisableCode] = useState("");
|
||||
|
||||
const handleSetupStart = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await setupTOTP();
|
||||
setQrCode(response.qr_code);
|
||||
setSecret(response.secret);
|
||||
setSetupStep("qr");
|
||||
setIsSettingUp(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to start TOTP setup");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (verificationCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await enableTOTP(verificationCode);
|
||||
setBackupCodes(response.backup_codes);
|
||||
setSetupStep("backup");
|
||||
toast.success("Two-factor authentication enabled successfully!");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Invalid verification code");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await disableTOTP(password || undefined, disableCode || undefined);
|
||||
setIsEnabled(false);
|
||||
setIsSettingUp(false);
|
||||
setSetupStep("init");
|
||||
setPassword("");
|
||||
setDisableCode("");
|
||||
onStatusChange?.(false);
|
||||
toast.success("Two-factor authentication disabled");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to disable TOTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateNewBackupCodes = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateBackupCodes(password || undefined, disableCode || undefined);
|
||||
setBackupCodes(response.backup_codes);
|
||||
toast.success("New backup codes generated");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to generate backup codes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const content = `Termix Two-Factor Authentication Backup Codes\n` +
|
||||
`Generated: ${new Date().toISOString()}\n\n` +
|
||||
`Keep these codes in a safe place. Each code can only be used once.\n\n` +
|
||||
backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'termix-backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Backup codes downloaded");
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setIsEnabled(true);
|
||||
setIsSettingUp(false);
|
||||
setSetupStep("init");
|
||||
setVerificationCode("");
|
||||
onStatusChange?.(true);
|
||||
};
|
||||
|
||||
if (isEnabled && !isSettingUp) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your account is protected with two-factor authentication
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>Enabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Two-factor authentication is currently active on your account
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="disable" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="disable">Disable 2FA</TabsTrigger>
|
||||
<TabsTrigger value="backup">Backup Codes</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="disable" className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
Disabling two-factor authentication will make your account less secure
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">Password or TOTP Code</Label>
|
||||
<Input
|
||||
id="disable-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Or</p>
|
||||
<Input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
placeholder="6-digit TOTP code"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDisable}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
Disable Two-Factor Authentication
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate new backup codes if you've lost your existing ones
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-password">Password or TOTP Code</Label>
|
||||
<Input
|
||||
id="backup-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Or</p>
|
||||
<Input
|
||||
id="backup-code"
|
||||
type="text"
|
||||
placeholder="6-digit TOTP code"
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateNewBackupCodes}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
Generate New Backup Codes
|
||||
</Button>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i}>{code}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "qr") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set Up Two-Factor Authentication</CardTitle>
|
||||
<CardDescription>
|
||||
Step 1: Scan the QR code with your authenticator app
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<img src={qrCode} alt="TOTP QR Code" className="w-64 h-64" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Manual Entry Code</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={secret}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(secret, "Secret key")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If you can't scan the QR code, enter this code manually in your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setSetupStep("verify")} className="w-full">
|
||||
Next: Verify Code
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "verify") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Your Authenticator</CardTitle>
|
||||
<CardDescription>
|
||||
Step 2: Enter the 6-digit code from your authenticator app
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verify-code">Verification Code</Label>
|
||||
<Input
|
||||
id="verify-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSetupStep("qr")}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading || verificationCode.length !== 6}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? "Verifying..." : "Verify and Enable"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "backup") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Save Your Backup Codes</CardTitle>
|
||||
<CardDescription>
|
||||
Step 3: Store these codes in a safe place
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{i + 1}.</span>
|
||||
<span>{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleComplete} className="w-full">
|
||||
Complete Setup
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add an extra layer of security to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Not Enabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={handleSetupStart} disabled={loading} className="w-full">
|
||||
{loading ? "Setting up..." : "Enable Two-Factor Authentication"}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
166
src/ui/User/UserProfile.tsx
Normal file
166
src/ui/User/UserProfile.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {User, Shield, Key, AlertCircle} from "lucide-react";
|
||||
import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx";
|
||||
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {PasswordReset} from "@/ui/User/PasswordReset.tsx";
|
||||
|
||||
interface UserProfileProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
const [userInfo, setUserInfo] = useState<{
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
totp_enabled: boolean;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
}, []);
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const info = await getUserInfo();
|
||||
setUserInfo({
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
totp_enabled: info.totp_enabled || false
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to load user information");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPStatusChange = (enabled: boolean) => {
|
||||
if (userInfo) {
|
||||
setUserInfo({...userInfo, totp_enabled: enabled});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto p-6">
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="animate-pulse">Loading user profile...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !userInfo) {
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error || "Failed to load user profile"}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto p-6 overflow-y-auto" style={{
|
||||
marginTop: isTopbarOpen ? '60px' : '0',
|
||||
transition: 'margin-top 0.3s ease',
|
||||
maxHeight: 'calc(100vh - 60px)'
|
||||
}}>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">User Profile</h1>
|
||||
<p className="text-muted-foreground mt-2">Manage your account settings and security</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profile" className="flex items-center gap-2">
|
||||
<User className="w-4 h-4"/>
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
{!userInfo.is_oidc && (
|
||||
<TabsTrigger value="security" className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4"/>
|
||||
Security
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
<CardDescription>Your account details and settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Username</Label>
|
||||
<p className="text-lg font-medium mt-1">{userInfo.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Account Type</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_admin ? "Administrator" : "User"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Authentication Method</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? "External (OIDC)" : "Local"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Two-Factor Authentication</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? (
|
||||
<span className="text-muted-foreground">Locked (OIDC Auth)</span>
|
||||
) : (
|
||||
userInfo.totp_enabled ? (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<Shield className="w-4 h-4"/>
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Disabled</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<TOTPSetup
|
||||
isEnabled={userInfo.totp_enabled}
|
||||
onStatusChange={handleTOTPStatusChange}
|
||||
/>
|
||||
|
||||
{!userInfo.is_oidc && (
|
||||
<PasswordReset
|
||||
userInfo={userInfo}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user