mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
fix: general qol additions and new analytics/telemetrics feature
This commit is contained in:
@@ -43,6 +43,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
@@ -293,6 +293,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Telemetry
|
||||
|
||||
Termix sends a small anonymous usage ping once every 24 hours to help understand how many instances are running and which features are actually used. This only includes a randomly generated instance ID, a count of users and hosts, the app version, and whether certain features (terminal, file manager, tunnels, docker, etc.) were used in the last 24 hours. It never includes usernames, hostnames, IP addresses, credentials, or any other identifying or connection data.
|
||||
|
||||
This is opt-out and enabled by default. You can disable it at any time in Admin Settings under **General**.
|
||||
|
||||
<br />
|
||||
|
||||
## Donate
|
||||
|
||||
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time. Donations also help fund the time to research and learn what's needed to build features like SAML, Kubernetes, and Agent support. Track progress and donate below.
|
||||
|
||||
+2
-1
@@ -58,7 +58,8 @@ WORKDIR /app
|
||||
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
NODE_ENV=production \
|
||||
POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
|
||||
update-ca-certificates && \
|
||||
|
||||
Generated
+9
-394
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
@@ -144,7 +144,7 @@
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~7.0.2",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
@@ -637,9 +637,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -657,9 +654,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -677,9 +671,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -697,9 +688,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6433,9 +6421,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6453,9 +6438,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6473,9 +6455,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6493,9 +6472,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7382,346 +7358,6 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/codemirror-extensions-basic-setup": {
|
||||
"version": "4.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz",
|
||||
@@ -17625,38 +17261,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -178,7 +178,7 @@
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~7.0.2",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
|
||||
@@ -738,6 +738,7 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_service_links (
|
||||
|
||||
@@ -816,6 +816,7 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -28,7 +28,7 @@ export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
domain: string;
|
||||
email: string;
|
||||
challengeType: "http-webroot" | "dns-cloudflare";
|
||||
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
|
||||
cloudflareToken: string;
|
||||
lastIssuedAt: string | null;
|
||||
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||
@@ -166,7 +166,7 @@ export function registerAcmeSSLRoutes(
|
||||
* type: string
|
||||
* challengeType:
|
||||
* type: string
|
||||
* enum: [http-webroot, dns-cloudflare]
|
||||
* enum: [http-webroot, dns-cloudflare, manual]
|
||||
* cloudflareToken:
|
||||
* type: string
|
||||
* responses:
|
||||
@@ -414,4 +414,158 @@ export function registerAcmeSSLRoutes(
|
||||
res.status(500).json({ error: `Certificate request failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/manual-ssl-upload:
|
||||
* post:
|
||||
* summary: Upload a manual/custom SSL certificate and key (admin only)
|
||||
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* certificate:
|
||||
* type: string
|
||||
* privateKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate uploaded and installed successfully.
|
||||
* 400:
|
||||
* description: Invalid or missing certificate/key.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Certificate installation failed.
|
||||
*/
|
||||
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const actor = await getAdminActor(userId);
|
||||
try {
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const { certificate, privateKey } = req.body;
|
||||
|
||||
if (
|
||||
typeof certificate !== "string" ||
|
||||
typeof privateKey !== "string" ||
|
||||
!certificate.includes("BEGIN CERTIFICATE") ||
|
||||
!privateKey.includes("PRIVATE KEY")
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "A valid PEM certificate and private key are required" });
|
||||
}
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
|
||||
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
|
||||
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
|
||||
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
|
||||
|
||||
try {
|
||||
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
error: "The provided certificate or private key is not valid PEM data",
|
||||
});
|
||||
}
|
||||
|
||||
const certPubkey = execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
const keyPubkey = execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-pubout"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
|
||||
if (!certPubkey.equals(keyPubkey)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "The certificate and private key do not match" });
|
||||
}
|
||||
|
||||
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||
await fs.rename(tmpCertFile, certDest);
|
||||
await fs.rename(tmpKeyFile, keyDest);
|
||||
await fs.chmod(keyDest, 0o600);
|
||||
await fs.chmod(certDest, 0o644);
|
||||
} finally {
|
||||
await fs.rm(tmpCertFile, { force: true });
|
||||
await fs.rm(tmpKeyFile, { force: true });
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const existing = await settingsRepository.get("acme_ssl_settings");
|
||||
const current = existing ? JSON.parse(existing) : {};
|
||||
const updated = {
|
||||
...current,
|
||||
challengeType: "manual",
|
||||
lastIssuedAt: new Date().toISOString(),
|
||||
};
|
||||
await settingsRepository.set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
authLogger.info("Manual SSL certificate installed", {
|
||||
operation: "manual_ssl_installed",
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ success: true }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true, ...(await getAcmeSettings()) });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
authLogger.error("Manual SSL certificate upload failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor?.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ error: message }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Certificate installation failed: ${message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1483,7 +1483,7 @@ router.get(
|
||||
* name: field
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [password, sudoPassword, vncPassword]
|
||||
* enum: [password, sudoPassword, vncPassword, key, keyPassword]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The requested password value.
|
||||
@@ -1499,7 +1499,11 @@ router.get(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const field = (req.query.field as string) || "password";
|
||||
|
||||
if (!["password", "sudoPassword", "vncPassword"].includes(field)) {
|
||||
if (
|
||||
!["password", "sudoPassword", "vncPassword", "key", "keyPassword"].includes(
|
||||
field,
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid field" });
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customThemes: row?.customThemes ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -106,6 +107,10 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* customThemes:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -175,6 +180,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* customThemes:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
@@ -201,6 +209,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
hiddenRailTabs,
|
||||
compactHostView,
|
||||
statusColorScheme,
|
||||
customThemes,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -221,6 +230,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customThemes?: string | null;
|
||||
};
|
||||
|
||||
const updates: UserPreferenceUpdate = {
|
||||
@@ -244,12 +254,41 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
storageMode,
|
||||
hiddenRailTabs,
|
||||
statusColorScheme,
|
||||
customThemes,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
return res.status(400).json({ error: `${key} must be a string` });
|
||||
}
|
||||
}
|
||||
|
||||
if (customThemes !== undefined && customThemes !== null) {
|
||||
let parsedThemes: unknown;
|
||||
try {
|
||||
parsedThemes = JSON.parse(customThemes);
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "customThemes must be a JSON-encoded array" });
|
||||
}
|
||||
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
|
||||
return res.status(400).json({
|
||||
error: "customThemes must be a JSON array of at most 100 themes",
|
||||
});
|
||||
}
|
||||
const isValidTheme = (entry: unknown): boolean =>
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
typeof (entry as { name?: unknown }).name === "string" &&
|
||||
!!(entry as { colors?: unknown }).colors &&
|
||||
typeof (entry as { colors?: unknown }).colors === "object";
|
||||
if (!parsedThemes.every(isValidTheme)) {
|
||||
return res.status(400).json({
|
||||
error: "Each custom theme must have an id, name, and colors object",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
@@ -294,6 +333,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
|
||||
if (statusColorScheme !== undefined)
|
||||
updates.statusColorScheme = statusColorScheme;
|
||||
if (customThemes !== undefined) updates.customThemes = customThemes;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -519,6 +519,103 @@ export function registerUserSettingsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
* get:
|
||||
* summary: Get analytics enabled setting
|
||||
* description: Returns whether anonymous usage telemetry is enabled.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Analytics enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/analytics-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"analytics_enabled",
|
||||
true,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get analytics enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get analytics enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
* patch:
|
||||
* summary: Update analytics enabled setting (admin only)
|
||||
* description: Enables or disables the daily anonymous usage telemetry heartbeat.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch("/analytics-enabled", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"analytics_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_analytics_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update analytics enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update analytics enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/host-defaults:
|
||||
|
||||
@@ -166,6 +166,12 @@ router.post("/token", async (req, res) => {
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* description: Override the host's default connection type
|
||||
* promptedUsername:
|
||||
* type: string
|
||||
* description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||
* promptedPassword:
|
||||
* type: string
|
||||
* description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection token generated successfully
|
||||
@@ -422,12 +428,22 @@ router.post(
|
||||
let username: string;
|
||||
let password: string;
|
||||
|
||||
const rdpAuthTypeForConnect = isSharedConnection
|
||||
? null
|
||||
: (host.rdpAuthType as string) ||
|
||||
(host.rdpCredentialId ? "credential" : "direct");
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
if (rdpAuthTypeForConnect === "none") {
|
||||
username = String(req.body?.promptedUsername || "");
|
||||
password = String(req.body?.promptedPassword || "");
|
||||
} else {
|
||||
username =
|
||||
(host.rdpUser as string) || (host.username as string) || "";
|
||||
password =
|
||||
(host.rdpPassword as string) || (host.password as string) || "";
|
||||
}
|
||||
port = (host.rdpPort as number) || port || 3389;
|
||||
break;
|
||||
case "vnc":
|
||||
|
||||
@@ -170,6 +170,9 @@ import {
|
||||
});
|
||||
}
|
||||
|
||||
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
|
||||
startAnalyticsHeartbeat();
|
||||
|
||||
systemLogger.success("Termix backend started successfully", {
|
||||
operation: "backend_init_complete",
|
||||
port: process.env.PORT || 4090,
|
||||
|
||||
@@ -47,6 +47,7 @@ describe("UserPreferenceRepository", () => {
|
||||
hidden_rail_tabs TEXT,
|
||||
compact_host_view INTEGER,
|
||||
status_color_scheme TEXT,
|
||||
custom_themes TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const mockGetBoolean = vi.fn();
|
||||
const mockGet = vi.fn();
|
||||
const mockSet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = ["from", "where", "groupBy"];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (
|
||||
cb: (v: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).then(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
getBoolean: mockGetBoolean,
|
||||
get: mockGet,
|
||||
set: mockSet,
|
||||
}),
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: vi.fn(() => makeChain([{ count: 0 }])),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
users: {},
|
||||
hosts: {},
|
||||
recentActivity: { type: "type", timestamp: "timestamp" },
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
Logger: class {
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: { post: mockPost },
|
||||
}));
|
||||
|
||||
describe("analytics", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("isAnalyticsEnabled defaults to true via the settings repository", async () => {
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
const result = await isAnalyticsEnabled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true);
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
|
||||
mockGet.mockResolvedValue("existing-id");
|
||||
const { getOrCreateInstanceId } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
expect(id).toBe("existing-id");
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
|
||||
mockGet.mockResolvedValue(null);
|
||||
const { getOrCreateInstanceId } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id);
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
|
||||
delete process.env.POSTHOG_API_KEY;
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(false);
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
mockGet.mockResolvedValue("instance-123");
|
||||
mockPost.mockResolvedValue({});
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
const [url, body] = mockPost.mock.calls[0];
|
||||
expect(url).toContain("/capture/");
|
||||
expect(body).toMatchObject({
|
||||
api_key: "phc_test",
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: "instance-123",
|
||||
properties: expect.objectContaining({
|
||||
user_count: 0,
|
||||
host_count: 0,
|
||||
used_terminal: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users, hosts, recentActivity } from "../database/db/schema.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentRepositoryContext,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { Logger } from "./logger.js";
|
||||
|
||||
export const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4");
|
||||
|
||||
const FEATURE_ACTIVITY_TYPES = [
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"tunnel",
|
||||
"docker",
|
||||
"telnet",
|
||||
"vnc",
|
||||
"rdp",
|
||||
"server_stats",
|
||||
] as const;
|
||||
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function isAnalyticsEnabled(): Promise<boolean> {
|
||||
return createCurrentSettingsRepository().getBoolean("analytics_enabled", true);
|
||||
}
|
||||
|
||||
export async function getOrCreateInstanceId(): Promise<string> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const existing = await settings.get("analytics_instance_id");
|
||||
if (existing) return existing;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
await settings.set("analytics_instance_id", id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getAppVersion(): string {
|
||||
return process.env.VERSION || "unknown";
|
||||
}
|
||||
|
||||
async function collectFeatureUsage(): Promise<Record<string, number>> {
|
||||
const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString();
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
type: recentActivity.type,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(recentActivity)
|
||||
.where(sql`${recentActivity.timestamp} >= ${since}`)
|
||||
.groupBy(recentActivity.type);
|
||||
|
||||
const counts = new Map(rows.map((row) => [row.type, Number(row.count)]));
|
||||
const usage: Record<string, number> = {};
|
||||
for (const type of FEATURE_ACTIVITY_TYPES) {
|
||||
usage[`used_${type}`] = counts.get(type) ?? 0;
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function collectCounts(): Promise<{
|
||||
userCount: number;
|
||||
hostCount: number;
|
||||
}> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const [userRows, hostRows] = await Promise.all([
|
||||
db.select({ count: sql<number>`count(*)` }).from(users),
|
||||
db.select({ count: sql<number>`count(*)` }).from(hosts),
|
||||
]);
|
||||
|
||||
return {
|
||||
userCount: Number(userRows[0]?.count ?? 0),
|
||||
hostCount: Number(hostRows[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectAndSendHeartbeat(): Promise<void> {
|
||||
const apiKey = process.env.POSTHOG_API_KEY;
|
||||
if (!apiKey) return;
|
||||
|
||||
try {
|
||||
if (!(await isAnalyticsEnabled())) return;
|
||||
|
||||
const instanceId = await getOrCreateInstanceId();
|
||||
const { userCount, hostCount } = await collectCounts();
|
||||
const featureUsage = await collectFeatureUsage();
|
||||
|
||||
await axios.post(
|
||||
`${POSTHOG_HOST}/capture/`,
|
||||
{
|
||||
api_key: apiKey,
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: instanceId,
|
||||
properties: {
|
||||
version: getAppVersion(),
|
||||
user_count: userCount,
|
||||
host_count: hostCount,
|
||||
...featureUsage,
|
||||
},
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
analyticsLogger.info("Sent daily usage heartbeat", {
|
||||
operation: "analytics_heartbeat_sent",
|
||||
});
|
||||
} catch (err) {
|
||||
analyticsLogger.warn("Failed to send usage heartbeat", {
|
||||
operation: "analytics_heartbeat_failed",
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function startAnalyticsHeartbeat(): void {
|
||||
if (!process.env.POSTHOG_API_KEY) {
|
||||
analyticsLogger.info(
|
||||
"Analytics disabled: POSTHOG_API_KEY not set",
|
||||
{ operation: "analytics_disabled_no_key" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void collectAndSendHeartbeat();
|
||||
setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
+2
-2
@@ -207,7 +207,7 @@ export interface Host {
|
||||
telnetUser?: string;
|
||||
telnetPassword?: string;
|
||||
telnetCredentialId?: number | null;
|
||||
rdpAuthType?: "direct" | "credential" | null;
|
||||
rdpAuthType?: "direct" | "credential" | "none" | null;
|
||||
vncAuthType?: "direct" | "credential" | null;
|
||||
telnetAuthType?: "direct" | "credential" | null;
|
||||
createdAt: string;
|
||||
@@ -329,7 +329,7 @@ export interface HostData {
|
||||
telnetUser?: string;
|
||||
telnetPassword?: string;
|
||||
telnetCredentialId?: number | null;
|
||||
rdpAuthType?: "direct" | "credential" | null;
|
||||
rdpAuthType?: "direct" | "credential" | "none" | null;
|
||||
vncAuthType?: "direct" | "credential" | null;
|
||||
telnetAuthType?: "direct" | "credential" | null;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export type Host = {
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
|
||||
rdpAuthType?: "direct" | "credential";
|
||||
rdpAuthType?: "direct" | "credential" | "none";
|
||||
rdpCredentialId?: string;
|
||||
rdpUser?: string;
|
||||
rdpPassword?: string;
|
||||
|
||||
+47
-5
@@ -222,11 +222,14 @@ export function AppShell({
|
||||
const [splitMode, setSplitMode] = useState<SplitMode>(
|
||||
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
|
||||
);
|
||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
||||
// paneTabIds holds live tab.id values, which change on every restore, so we
|
||||
// can't restore it from storage directly. It starts empty and gets filled in
|
||||
// once by the reconciliation effect below, keyed off the stable instanceId
|
||||
// values saved in termix_paneInstanceIds.
|
||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() =>
|
||||
Array(6).fill(null),
|
||||
);
|
||||
const paneLayoutRestoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
paneTabIdsRef.current = paneTabIds;
|
||||
}, [paneTabIds]);
|
||||
@@ -262,8 +265,18 @@ export function AppShell({
|
||||
}, [splitMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds));
|
||||
}, [paneTabIds]);
|
||||
// Don't overwrite the saved layout with the empty initial state before
|
||||
// reconciliation has had a chance to restore it.
|
||||
if (!paneLayoutRestoredRef.current) return;
|
||||
const instanceIds = paneTabIds.map((id) => {
|
||||
if (id == null) return null;
|
||||
return tabs.find((t) => t.id === id)?.instanceId ?? null;
|
||||
});
|
||||
localStorage.setItem(
|
||||
"termix_paneInstanceIds",
|
||||
JSON.stringify(instanceIds),
|
||||
);
|
||||
}, [paneTabIds, tabs]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -976,6 +989,35 @@ export function AppShell({
|
||||
loadSavedTabs();
|
||||
}, [hostsLoaded, userPrefsLoaded]);
|
||||
|
||||
// Restore split-screen pane assignments once tabs are settled. Saved assignments are
|
||||
// keyed by instanceId (stable across reloads) and remapped to the live tab.id here,
|
||||
// since tab.id is regenerated every time a tab is (re)opened.
|
||||
useEffect(() => {
|
||||
if (!tabsReady || paneLayoutRestoredRef.current) return;
|
||||
paneLayoutRestoredRef.current = true;
|
||||
|
||||
try {
|
||||
const savedInstanceIds: (string | null)[] = JSON.parse(
|
||||
localStorage.getItem("termix_paneInstanceIds") ?? "null",
|
||||
);
|
||||
if (!Array.isArray(savedInstanceIds)) return;
|
||||
|
||||
const restored = savedInstanceIds.map((instanceId) => {
|
||||
if (instanceId == null) return null;
|
||||
return tabs.find((t) => t.instanceId === instanceId)?.id ?? null;
|
||||
});
|
||||
if (restored.some((id) => id != null)) {
|
||||
setPaneTabIds(restored);
|
||||
} else {
|
||||
// None of the saved panes could be restored (e.g. reopen-tabs-on-login
|
||||
// is disabled), so drop back to a single view instead of an empty split.
|
||||
setSplitMode("none");
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, [tabsReady, tabs]);
|
||||
|
||||
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
||||
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare";
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
|
||||
|
||||
export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
@@ -45,3 +45,15 @@ export async function requestAcmeCertificate(): Promise<
|
||||
handleApiError(error, "request ACME certificate");
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadManualSslCertificate(payload: {
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
}): Promise<AcmeSettings & { success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/manual-ssl-upload", payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "upload manual SSL certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,12 @@ export async function adminDeleteUserHost(
|
||||
export async function adminGetHostPassword(
|
||||
targetUserId: string,
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "vncPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -96,7 +96,12 @@ export async function getSSHHostWithCredentials(
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "vncPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -208,12 +208,18 @@ export async function getGuacamoleToken(
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
protocol?: "rdp" | "vnc" | "telnet",
|
||||
promptedCredentials?: { username?: string; password?: string },
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/guacamole/connect-host/${hostId}`,
|
||||
protocol ? { protocol } : {},
|
||||
);
|
||||
const response = await authApi.post(`/guacamole/connect-host/${hostId}`, {
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(promptedCredentials?.username
|
||||
? { promptedUsername: promptedCredentials.username }
|
||||
: {}),
|
||||
...(promptedCredentials?.password
|
||||
? { promptedPassword: promptedCredentials.password }
|
||||
: {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||
import type { TerminalTheme } from "@/lib/terminal-themes";
|
||||
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
@@ -82,6 +83,12 @@ export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
// USER PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export interface SavedCustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: TerminalTheme["colors"];
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
reopenTabsOnLogin: boolean;
|
||||
theme?: string | null;
|
||||
@@ -102,6 +109,17 @@ export interface UserPreferences {
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customThemes?: string | null;
|
||||
}
|
||||
|
||||
export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
|
||||
@@ -145,6 +145,32 @@ export async function updateGuacamoleSettings(settings: {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ANALYTICS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAnalyticsEnabled(): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/analytics-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAnalyticsEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/analytics-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST DEFAULTS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -20,6 +20,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
@@ -97,7 +106,10 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<SSHHost, "connectionType" | "guacamoleConfig">;
|
||||
hostConfig: Pick<
|
||||
SSHHost,
|
||||
"connectionType" | "guacamoleConfig" | "rdpAuthType"
|
||||
>;
|
||||
hostName: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
@@ -123,12 +135,31 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
|
||||
const resolvedProtocolForConnect = (protocol ??
|
||||
hostConfig.connectionType ??
|
||||
"rdp") as "rdp" | "vnc" | "telnet";
|
||||
const needsCredentialPrompt =
|
||||
resolvedProtocolForConnect === "rdp" && hostConfig.rdpAuthType === "none";
|
||||
|
||||
const [promptedCredentials, setPromptedCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt);
|
||||
const [promptUsername, setPromptUsername] = useState("");
|
||||
const [promptPassword, setPromptPassword] = useState("");
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => displayRef.current?.disconnect(),
|
||||
isConnected: () => displayRef.current?.isConnected() === true,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (needsCredentialPrompt && !promptedCredentials) {
|
||||
setPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(null);
|
||||
setError(null);
|
||||
getGuacdStatus()
|
||||
@@ -137,26 +168,44 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
setError(t("guacamole.guacdUnavailable"));
|
||||
return;
|
||||
}
|
||||
return getGuacamoleTokenFromHost(hostId, protocol);
|
||||
return getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
);
|
||||
})
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
setToken(result.token);
|
||||
const resolvedProtocol = (protocol ??
|
||||
hostConfig.connectionType ??
|
||||
"rdp") as "rdp" | "vnc" | "telnet";
|
||||
logActivity(resolvedProtocol, hostId, hostName).catch(() => {});
|
||||
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostConfig.connectionType, hostId, hostName, protocol, retryCount, t]);
|
||||
}, [
|
||||
hostId,
|
||||
hostName,
|
||||
protocol,
|
||||
retryCount,
|
||||
t,
|
||||
needsCredentialPrompt,
|
||||
promptedCredentials,
|
||||
resolvedProtocolForConnect,
|
||||
]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
if (needsCredentialPrompt) {
|
||||
setPromptedCredentials(null);
|
||||
setPromptUsername("");
|
||||
setPromptPassword("");
|
||||
setPromptOpen(true);
|
||||
}
|
||||
setRetryCount((c) => c + 1);
|
||||
}, []);
|
||||
}, [needsCredentialPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabId) return;
|
||||
@@ -169,6 +218,67 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
window.removeEventListener("termix:refresh-guacamole", handler);
|
||||
}, [tabId, handleReconnect]);
|
||||
|
||||
if (promptOpen) {
|
||||
return (
|
||||
<Dialog
|
||||
open={promptOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPromptOpen(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("guacamole.credentialPromptTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("guacamole.credentialPromptDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4 mt-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPromptedCredentials({
|
||||
username: promptUsername,
|
||||
password: promptPassword,
|
||||
});
|
||||
setPromptOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.username")}
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Administrator"
|
||||
value={promptUsername}
|
||||
onChange={(e) => setPromptUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={promptPassword}
|
||||
onChange={(e) => setPromptPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("guacamole.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
@@ -216,10 +326,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedProtocol = (protocol ?? hostConfig.connectionType) as
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
const resolvedProtocol = resolvedProtocolForConnect;
|
||||
const configuredDpi = Number(hostConfig.guacamoleConfig?.dpi);
|
||||
|
||||
return (
|
||||
|
||||
+5
-5
@@ -112,7 +112,7 @@
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.2115 0.0042 128.68);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--muted-foreground: oklch(0.44 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.2115 0.0042 128.68);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
@@ -479,19 +479,19 @@
|
||||
|
||||
/* Font size scale applied to html element */
|
||||
html.fs-xs {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
html.fs-sm {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
html.fs-md {
|
||||
font-size: 14px;
|
||||
}
|
||||
html.fs-lg {
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
html.fs-xl {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
|
||||
@@ -763,6 +763,14 @@
|
||||
"customThemeBrightCyan": "Bright Cyan",
|
||||
"customThemeBrightWhite": "Bright White",
|
||||
"customThemeResetTooltip": "Reset to defaults",
|
||||
"savedThemesLabel": "Saved Themes",
|
||||
"saveAsGlobalTheme": "Save as Global Theme",
|
||||
"saveGlobalThemeNamePrompt": "Enter a name for this theme",
|
||||
"saveGlobalThemeSuccess": "Theme saved",
|
||||
"saveGlobalThemeError": "Failed to save theme",
|
||||
"applyGlobalThemeTooltip": "Apply this theme",
|
||||
"deleteGlobalThemeTooltip": "Delete this theme",
|
||||
"noSavedThemes": "No saved themes yet",
|
||||
"syntaxHighlightingLabel": "Syntax Highlighting",
|
||||
"syntaxHighlightingDesc": "Colorize terminal output (errors, paths, IPs, timestamps)",
|
||||
"syntaxHighlightingCategories": "Highlight Categories",
|
||||
@@ -963,6 +971,9 @@
|
||||
"failedToSaveCredential": "Failed to save credential",
|
||||
"credentialNameRequired": "Please enter a name for the credential",
|
||||
"credentialAuthRequired": "Add a password, an SSH key, or both",
|
||||
"createCredentialFromHostBtn": "Create Credential",
|
||||
"createCredentialFromHostTitle": "Create Credential From Host",
|
||||
"createCredentialFromHostDesc": "Create a reusable, shareable credential entry prefilled with this host's current username, password, and/or SSH key.",
|
||||
"backToHosts": "Back to Hosts",
|
||||
"backToCredentials": "Back to Credentials",
|
||||
"pinned": "Pinned",
|
||||
@@ -1252,6 +1263,8 @@
|
||||
"authMethod": "Auth Method",
|
||||
"authTypeDirect": "Direct",
|
||||
"authTypeCredential": "Credential",
|
||||
"authTypeNone": "None",
|
||||
"authTypeNoneDesc": "No credentials are stored. You'll be prompted for a username and password each time you connect; they are not saved.",
|
||||
"selectCredential": "Select a credential...",
|
||||
"connectionSettings": "Connection Settings",
|
||||
"displaySettings": "Display Settings",
|
||||
@@ -1446,6 +1459,9 @@
|
||||
"reconnect": "Reconnect",
|
||||
"retry": "Retry",
|
||||
"guacdUnavailable": "Remote desktop service (guacd) is not available. Please ensure guacd is running and accessible and configured properly in admin settings.",
|
||||
"credentialPromptTitle": "Enter RDP Credentials",
|
||||
"credentialPromptDescription": "This host is set to prompt for credentials on connect. They are used for this session only and are not saved.",
|
||||
"connect": "Connect",
|
||||
"ctrlAltDel": "Ctrl+Alt+Del",
|
||||
"toolbar": {
|
||||
"ctrlAltDel": "Ctrl+Alt+Del",
|
||||
@@ -2656,6 +2672,17 @@
|
||||
"sslSaveFailed": "Failed to save SSL settings",
|
||||
"sslRequiresDomain": "Domain and email are required",
|
||||
"sslInfoNote": "After issuing a certificate, enable SSL in your environment variables (ENABLE_SSL=true) and restart Termix.",
|
||||
"sslManualOption": "Manual (upload certificate)",
|
||||
"sslManualCert": "Certificate (PEM)",
|
||||
"sslManualCertPlaceholder": "-----BEGIN CERTIFICATE-----",
|
||||
"sslManualKey": "Private Key (PEM)",
|
||||
"sslManualKeyPlaceholder": "-----BEGIN PRIVATE KEY-----",
|
||||
"sslManualDesc": "Paste your existing certificate and private key, including a full chain if required by your CA.",
|
||||
"sslManualUpload": "Upload & Install Certificate",
|
||||
"sslManualUploadLoading": "Uploading certificate...",
|
||||
"sslManualUploadSuccess": "Certificate uploaded and installed successfully",
|
||||
"sslManualUploadFailed": "Certificate upload failed",
|
||||
"sslManualRequiresFields": "Certificate and private key are required",
|
||||
"auditLogTotal": "{{total}} total entries",
|
||||
"auditLogEmpty": "No audit log entries found",
|
||||
"auditLogSuccess": "Success",
|
||||
@@ -2684,6 +2711,9 @@
|
||||
"commandHistoryEnabled": "Command History",
|
||||
"commandHistoryEnabledDesc": "Allow command history recording. When disabled, history is not saved regardless of per-host settings.",
|
||||
"updateCommandHistoryFailed": "Failed to update command history setting",
|
||||
"analyticsEnabled": "Share Anonymous Usage Statistics",
|
||||
"analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.",
|
||||
"updateAnalyticsFailed": "Failed to update analytics setting",
|
||||
"sessionTimeout": "Session Timeout",
|
||||
"hours": "hours",
|
||||
"sessionTimeoutRange": "Min 1h · Max 720h",
|
||||
@@ -3146,6 +3176,7 @@
|
||||
"sectionAppearance": "Appearance",
|
||||
"sectionSecurity": "Security",
|
||||
"sectionApiKeys": "API Keys",
|
||||
"sectionData": "Data",
|
||||
"sectionC2sTunnels": "C2S Tunnels",
|
||||
"usernameLabel": "Username",
|
||||
"roleLabel": "Role",
|
||||
@@ -3240,6 +3271,22 @@
|
||||
"apiKeyUsageHint": "Include your key in the",
|
||||
"apiKeyUsageHintHeader": "header.",
|
||||
"apiKeyPermissionsHint": "Keys inherit the permissions of the creating user.",
|
||||
"exportData": "Export My Data",
|
||||
"exportDataDesc": "Download a backup of your hosts, credentials, and settings to transfer to another device",
|
||||
"export": "Export",
|
||||
"exporting": "Exporting...",
|
||||
"importData": "Import My Data",
|
||||
"importDataDesc": "Restore your hosts, credentials, and settings from a .sqlite backup file",
|
||||
"importDataSelected": "Selected: {{name}}",
|
||||
"selectFile": "Select File",
|
||||
"changeFile": "Change",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"exportSuccess": "Data exported successfully",
|
||||
"exportFailed": "Data export failed",
|
||||
"importSelectFile": "Please select a file first",
|
||||
"importCompleted": "Import completed: {{total}} items imported, {{skipped}} skipped",
|
||||
"importFailed": "Import failed: {{error}}",
|
||||
"roleUser": "User",
|
||||
"authMethodDual": "Dual Auth",
|
||||
"authMethodOidc": "OIDC",
|
||||
|
||||
@@ -140,12 +140,15 @@ export function CommandPalette({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [isOpen, setIsOpen]);
|
||||
|
||||
const filteredHosts = hosts.filter(
|
||||
(h) =>
|
||||
h.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
h.ip.toLowerCase().includes(search.toLowerCase()) ||
|
||||
h.username.toLowerCase().includes(search.toLowerCase()),
|
||||
const filteredHosts = hosts.filter((h) => {
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
h.name.toLowerCase().includes(query) ||
|
||||
h.ip.toLowerCase().includes(query) ||
|
||||
h.username.toLowerCase().includes(query) ||
|
||||
h.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
// Group hosts by folder; ungrouped hosts appear first under an implicit root group
|
||||
const groupedHosts: { folder: string | null; hosts: Host[] }[] = [];
|
||||
|
||||
@@ -41,12 +41,15 @@ import {
|
||||
updateTailscaleSettings,
|
||||
getHostDefaults,
|
||||
updateHostDefaults,
|
||||
getAnalyticsEnabled,
|
||||
updateAnalyticsEnabled,
|
||||
type HostDefaults,
|
||||
} from "@/api/settings-api";
|
||||
import {
|
||||
getAcmeSslSettings,
|
||||
updateAcmeSslSettings,
|
||||
requestAcmeCertificate,
|
||||
uploadManualSslCertificate,
|
||||
type AcmeSettings,
|
||||
} from "@/api/acme-ssl-api";
|
||||
import {
|
||||
@@ -126,6 +129,7 @@ export function AdminSettingsPanel({
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
|
||||
// SSO / auto-provision state
|
||||
@@ -200,6 +204,9 @@ export function AdminSettingsPanel({
|
||||
useState<AcmeSettings>(defaultAcmeSettings);
|
||||
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
|
||||
const [acmeRequesting, setAcmeRequesting] = useState(false);
|
||||
const [manualCertDraft, setManualCertDraft] = useState("");
|
||||
const [manualKeyDraft, setManualKeyDraft] = useState("");
|
||||
const [manualUploading, setManualUploading] = useState(false);
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||
@@ -281,6 +288,7 @@ export function AdminSettingsPanel({
|
||||
oidcSilent,
|
||||
tailscale,
|
||||
cmdHistory,
|
||||
analytics,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
getPasswordLoginAllowed(),
|
||||
@@ -293,6 +301,7 @@ export function AdminSettingsPanel({
|
||||
getOidcSilentLoginDefault(),
|
||||
getTailscaleSettings(),
|
||||
getCommandHistoryEnabled(),
|
||||
getAnalyticsEnabled(),
|
||||
]);
|
||||
|
||||
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
|
||||
@@ -324,6 +333,9 @@ export function AdminSettingsPanel({
|
||||
if (cmdHistory.status === "fulfilled") {
|
||||
setCommandHistoryEnabled(cmdHistory.value.enabled);
|
||||
}
|
||||
if (analytics.status === "fulfilled") {
|
||||
setAnalyticsEnabled(analytics.value.enabled);
|
||||
}
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
@@ -431,6 +443,17 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAnalytics() {
|
||||
const newVal = !analyticsEnabled;
|
||||
setAnalyticsEnabled(newVal);
|
||||
try {
|
||||
await updateAnalyticsEnabled(newVal);
|
||||
} catch {
|
||||
setAnalyticsEnabled(!newVal);
|
||||
toast.error(t("admin.updateAnalyticsFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSessionTimeout() {
|
||||
const hours = parseInt(sessionTimeout, 10);
|
||||
if (isNaN(hours) || hours < 1 || hours > 720) {
|
||||
@@ -592,6 +615,28 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualSslUpload() {
|
||||
if (!manualCertDraft.trim() || !manualKeyDraft.trim()) {
|
||||
toast.error(t("admin.sslManualRequiresFields"));
|
||||
return;
|
||||
}
|
||||
setManualUploading(true);
|
||||
try {
|
||||
const result = await uploadManualSslCertificate({
|
||||
certificate: manualCertDraft,
|
||||
privateKey: manualKeyDraft,
|
||||
});
|
||||
setAcmeSettings(result);
|
||||
setManualCertDraft("");
|
||||
setManualKeyDraft("");
|
||||
toast.success(t("admin.sslManualUploadSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed")));
|
||||
} finally {
|
||||
setManualUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderSaved(saved: SSOProvider) {
|
||||
setSsoProviders((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === saved.id);
|
||||
@@ -861,6 +906,8 @@ export function AdminSettingsPanel({
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSections.has("general")}
|
||||
onToggle={() => toggle("general")}
|
||||
analyticsEnabled={analyticsEnabled}
|
||||
handleToggleAnalytics={handleToggleAnalytics}
|
||||
allowRegistration={allowRegistration}
|
||||
handleToggleRegistration={handleToggleRegistration}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
@@ -982,6 +1029,12 @@ export function AdminSettingsPanel({
|
||||
requesting={acmeRequesting}
|
||||
handleSave={handleSaveAcmeSettings}
|
||||
handleRequest={handleRequestAcmeCertificate}
|
||||
manualCertDraft={manualCertDraft}
|
||||
setManualCertDraft={setManualCertDraft}
|
||||
manualKeyDraft={manualKeyDraft}
|
||||
setManualKeyDraft={setManualKeyDraft}
|
||||
manualUploading={manualUploading}
|
||||
handleManualUpload={handleManualSslUpload}
|
||||
/>
|
||||
|
||||
<AdminApiKeysSection
|
||||
|
||||
@@ -30,6 +30,8 @@ import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
|
||||
type GeneralSettingsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
analyticsEnabled: boolean;
|
||||
handleToggleAnalytics: () => void;
|
||||
allowRegistration: boolean;
|
||||
handleToggleRegistration: () => void;
|
||||
allowPasswordLogin: boolean;
|
||||
@@ -67,6 +69,8 @@ type GeneralSettingsSectionProps = {
|
||||
export function AdminGeneralSettingsSection({
|
||||
open,
|
||||
onToggle,
|
||||
analyticsEnabled,
|
||||
handleToggleAnalytics,
|
||||
allowRegistration,
|
||||
handleToggleRegistration,
|
||||
allowPasswordLogin,
|
||||
@@ -110,6 +114,12 @@ export function AdminGeneralSettingsSection({
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
<SettingRow
|
||||
label={t("admin.analyticsEnabled")}
|
||||
description={t("admin.analyticsEnabledDesc")}
|
||||
>
|
||||
<AdminToggle on={analyticsEnabled} onToggle={handleToggleAnalytics} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.allowRegistration")}
|
||||
description={t("admin.allowRegistrationDesc")}
|
||||
@@ -1038,6 +1048,12 @@ type AdminSSLSectionProps = {
|
||||
requesting: boolean;
|
||||
handleSave: () => void;
|
||||
handleRequest: () => void;
|
||||
manualCertDraft: string;
|
||||
setManualCertDraft: Dispatch<SetStateAction<string>>;
|
||||
manualKeyDraft: string;
|
||||
setManualKeyDraft: Dispatch<SetStateAction<string>>;
|
||||
manualUploading: boolean;
|
||||
handleManualUpload: () => void;
|
||||
};
|
||||
|
||||
export function AdminSSLSection({
|
||||
@@ -1050,6 +1066,12 @@ export function AdminSSLSection({
|
||||
requesting,
|
||||
handleSave,
|
||||
handleRequest,
|
||||
manualCertDraft,
|
||||
setManualCertDraft,
|
||||
manualKeyDraft,
|
||||
setManualKeyDraft,
|
||||
manualUploading,
|
||||
handleManualUpload,
|
||||
}: AdminSSLSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1107,6 +1129,41 @@ export function AdminSSLSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslChallengeType")}
|
||||
</label>
|
||||
<Select
|
||||
value={settings.challengeType}
|
||||
onValueChange={(v) =>
|
||||
setSettings((p) => ({
|
||||
...p,
|
||||
challengeType: v as AcmeChallengeType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http-webroot" className="text-xs">
|
||||
HTTP (webroot)
|
||||
</SelectItem>
|
||||
<SelectItem value="dns-cloudflare" className="text-xs">
|
||||
DNS (Cloudflare)
|
||||
</SelectItem>
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
{t("admin.sslManualOption")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslChallengeTypeDesc")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{settings.challengeType !== "manual" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslDomain")}
|
||||
@@ -1134,36 +1191,8 @@ export function AdminSSLSection({
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslChallengeType")}
|
||||
</label>
|
||||
<Select
|
||||
value={settings.challengeType}
|
||||
onValueChange={(v) =>
|
||||
setSettings((p) => ({
|
||||
...p,
|
||||
challengeType: v as AcmeChallengeType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http-webroot" className="text-xs">
|
||||
HTTP (webroot)
|
||||
</SelectItem>
|
||||
<SelectItem value="dns-cloudflare" className="text-xs">
|
||||
DNS (Cloudflare)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslChallengeTypeDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{settings.challengeType === "dns-cloudflare" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -1185,10 +1214,61 @@ export function AdminSSLSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.challengeType === "manual" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslManualCert")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={manualCertDraft}
|
||||
onChange={(e) => setManualCertDraft(e.target.value)}
|
||||
placeholder={t("admin.sslManualCertPlaceholder")}
|
||||
spellCheck={false}
|
||||
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslManualKey")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={manualKeyDraft}
|
||||
onChange={(e) => setManualKeyDraft(e.target.value)}
|
||||
placeholder={t("admin.sslManualKeyPlaceholder")}
|
||||
spellCheck={false}
|
||||
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslManualDesc")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleManualUpload}
|
||||
disabled={manualUploading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{manualUploading
|
||||
? t("admin.sslManualUploadLoading")
|
||||
: t("admin.sslManualUpload")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
|
||||
{t("admin.sslInfoNote")}
|
||||
</span>
|
||||
|
||||
{settings.challengeType !== "manual" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1213,6 +1293,7 @@ export function AdminSSLSection({
|
||||
: t("admin.sslRequestCert")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
adminUpdateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
|
||||
type CredentialWithCertificate = Credential & { certPublicKey?: string };
|
||||
|
||||
@@ -151,19 +152,11 @@ export function CredentialEditorView({
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.folder")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Server Keys"
|
||||
<FolderPathPicker
|
||||
value={credForm.folder}
|
||||
onChange={(e) => setCredField("folder", e.target.value)}
|
||||
list="cred-folder-suggestions"
|
||||
onChange={(path) => setCredField("folder", path)}
|
||||
folderPaths={existingFolders}
|
||||
/>
|
||||
{existingFolders.length > 0 && (
|
||||
<datalist id="cred-folder-suggestions">
|
||||
{existingFolders.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
|
||||
@@ -43,8 +43,16 @@ import {
|
||||
adminUpdateUserHost,
|
||||
adminGetHostPassword,
|
||||
adminGetUserSnippets,
|
||||
createCredential,
|
||||
adminCreateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
|
||||
import {
|
||||
getUserPreferences,
|
||||
saveUserPreferences,
|
||||
parseCustomThemes,
|
||||
type SavedCustomTheme,
|
||||
} from "@/api/open-tabs-api";
|
||||
import type { Host, VaultProfile } from "@/types/ui-types";
|
||||
import type { SSHHost, TunnelStatus } from "@/types";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
@@ -131,6 +139,66 @@ export function HostEditor({
|
||||
const [isOidcUser, setIsOidcUser] = useState(false);
|
||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||
const [showVaultManager, setShowVaultManager] = useState(false);
|
||||
const [quickCredentialName, setQuickCredentialName] = useState("");
|
||||
const [creatingQuickCredential, setCreatingQuickCredential] =
|
||||
useState(false);
|
||||
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
||||
useState(false);
|
||||
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
||||
const [savingTheme, setSavingTheme] = useState(false);
|
||||
|
||||
const reloadSavedThemes = () => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => setSavedThemes(parseCustomThemes(prefs.customThemes)))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadSavedThemes();
|
||||
}, []);
|
||||
|
||||
const handleSaveAsGlobalTheme = async () => {
|
||||
const colors = form.customThemeColors;
|
||||
if (!colors) return;
|
||||
const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt"));
|
||||
if (!name || !name.trim()) return;
|
||||
setSavingTheme(true);
|
||||
try {
|
||||
const newTheme: SavedCustomTheme = {
|
||||
id: `theme-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: name.trim(),
|
||||
colors,
|
||||
};
|
||||
const updated = [...savedThemes, newTheme];
|
||||
await saveUserPreferences({
|
||||
customThemes: JSON.stringify(updated),
|
||||
});
|
||||
setSavedThemes(updated);
|
||||
toast.success(t("hosts.saveGlobalThemeSuccess"));
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
} finally {
|
||||
setSavingTheme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGlobalTheme = async (id: string) => {
|
||||
const updated = savedThemes.filter((theme) => theme.id !== id);
|
||||
try {
|
||||
await saveUserPreferences({
|
||||
customThemes: JSON.stringify(updated),
|
||||
});
|
||||
setSavedThemes(updated);
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGlobalTheme = (id: string) => {
|
||||
const theme = savedThemes.find((entry) => entry.id === id);
|
||||
if (!theme) return;
|
||||
setField("customThemeColors", { ...theme.colors });
|
||||
};
|
||||
|
||||
const reloadVaultProfiles = () => {
|
||||
getVaultProfiles()
|
||||
@@ -227,6 +295,77 @@ export function HostEditor({
|
||||
(c) => c.id === form.credentialId,
|
||||
);
|
||||
|
||||
const canQuickCreateCredential =
|
||||
(authMethod === "password" || authMethod === "key") &&
|
||||
(authMethod === "password"
|
||||
? !!form.password || !!host?.hasPassword
|
||||
: (!!form.key && form.key !== "existing_key") || !!host?.hasKey);
|
||||
|
||||
const openQuickCredentialDialog = () => {
|
||||
setQuickCredentialName(form.name || form.username || "");
|
||||
setShowQuickCredentialDialog(true);
|
||||
};
|
||||
|
||||
const handleQuickCreateCredential = async () => {
|
||||
if (!quickCredentialName.trim()) {
|
||||
toast.error(t("hosts.credentialNameRequired"));
|
||||
return;
|
||||
}
|
||||
setCreatingQuickCredential(true);
|
||||
try {
|
||||
const fetchField = (field: "password" | "key" | "keyPassword") =>
|
||||
adminTargetUserId
|
||||
? adminGetHostPassword(adminTargetUserId, Number(host?.id), field)
|
||||
: getHostPassword(Number(host?.id), field);
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
name: quickCredentialName,
|
||||
username: form.username || null,
|
||||
folder: form.folder || null,
|
||||
};
|
||||
|
||||
if (authMethod === "password") {
|
||||
data.authType = "password";
|
||||
data.password =
|
||||
form.password ||
|
||||
(host?.hasPassword ? await fetchField("password") : null);
|
||||
} else {
|
||||
const key =
|
||||
form.key && form.key !== "existing_key"
|
||||
? form.key
|
||||
: host?.hasKey
|
||||
? await fetchField("key")
|
||||
: null;
|
||||
const keyPassword =
|
||||
form.keyPassword && form.keyPassword !== "existing_key_password"
|
||||
? form.keyPassword
|
||||
: host?.hasKeyPassword
|
||||
? await fetchField("keyPassword")
|
||||
: null;
|
||||
data.authType = "key";
|
||||
data.key = key;
|
||||
data.keyPassword = keyPassword;
|
||||
data.password = form.password || null;
|
||||
}
|
||||
|
||||
if (adminTargetUserId) {
|
||||
await adminCreateUserCredential(adminTargetUserId, data);
|
||||
} else {
|
||||
await createCredential(data);
|
||||
}
|
||||
toast.success(t("hosts.credentialCreated"));
|
||||
if (!adminTargetUserId) {
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
}
|
||||
setShowQuickCredentialDialog(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
toast.error(msg || t("hosts.failedToSaveCredential"));
|
||||
} finally {
|
||||
setCreatingQuickCredential(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Shared hosts: view-level recipients see a read-only editor; edit-level
|
||||
// recipients may change the host but never its credential/vault references
|
||||
// or auth type (owner-only, enforced server-side too).
|
||||
@@ -321,6 +460,20 @@ export function HostEditor({
|
||||
<SectionCard
|
||||
title={t("hosts.authenticationLabel")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
action={
|
||||
canQuickCreateCredential && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
||||
onClick={openQuickCredentialDialog}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
{t("hosts.createCredentialFromHostBtn")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -1060,6 +1213,66 @@ export function HostEditor({
|
||||
</div>
|
||||
{form.theme === "custom" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.savedThemesLabel")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px]"
|
||||
disabled={savingTheme || !form.customThemeColors}
|
||||
onClick={handleSaveAsGlobalTheme}
|
||||
>
|
||||
{t("hosts.saveAsGlobalTheme")}
|
||||
</Button>
|
||||
</div>
|
||||
{savedThemes.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.noSavedThemes")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{savedThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between gap-2 border border-border px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.applyGlobalThemeTooltip")}
|
||||
onClick={() =>
|
||||
handleApplyGlobalTheme(theme.id)
|
||||
}
|
||||
className="flex items-center gap-2 text-xs text-left flex-1 min-w-0 hover:text-foreground transition-colors"
|
||||
>
|
||||
<span
|
||||
className="size-3.5 shrink-0 border border-border"
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{theme.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.deleteGlobalThemeTooltip")}
|
||||
onClick={() =>
|
||||
handleDeleteGlobalTheme(theme.id)
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.customThemeColors")}
|
||||
@@ -2112,6 +2325,58 @@ export function HostEditor({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQuickCredentialDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold">
|
||||
{t("hosts.createCredentialFromHostTitle")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowQuickCredentialDialog(false)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.createCredentialFromHostDesc")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Production SSH Key"
|
||||
value={quickCredentialName}
|
||||
onChange={(e) => setQuickCredentialName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowQuickCredentialDialog(false)}
|
||||
disabled={creatingQuickCredential}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleQuickCreateCredential}
|
||||
disabled={creatingQuickCredential}
|
||||
>
|
||||
{creatingQuickCredential
|
||||
? t("hosts.savingBtn")
|
||||
: t("hosts.addCredentialBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,7 +205,8 @@ export function createHostEditorForm(
|
||||
rdpAuthType: (host?.rdpAuthType ??
|
||||
(host?.rdpCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
| "credential",
|
||||
| "credential"
|
||||
| "none",
|
||||
vncAuthType: (host?.vncAuthType ??
|
||||
(host?.vncCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
|
||||
@@ -174,13 +174,20 @@ export function HostEditorRdpTab({
|
||||
icon={<Shield className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{credentials && credentials.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.authMethod")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["direct", "credential"] as const).map((m) => (
|
||||
{(
|
||||
[
|
||||
"direct",
|
||||
...(credentials && credentials.length > 0
|
||||
? (["credential"] as const)
|
||||
: []),
|
||||
"none",
|
||||
] as const
|
||||
).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
@@ -198,7 +205,11 @@ export function HostEditorRdpTab({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{form.rdpAuthType === "none" ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.guac.authTypeNoneDesc")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{form.rdpAuthType === "credential" &&
|
||||
credentials &&
|
||||
@@ -209,10 +220,14 @@ export function HostEditorRdpTab({
|
||||
</label>
|
||||
<select
|
||||
value={form.rdpCredentialId}
|
||||
onChange={(e) => setField("rdpCredentialId", e.target.value)}
|
||||
onChange={(e) =>
|
||||
setField("rdpCredentialId", e.target.value)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.guac.selectCredential")}</option>
|
||||
<option value="">
|
||||
{t("hosts.guac.selectCredential")}
|
||||
</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
@@ -240,7 +255,9 @@ export function HostEditorRdpTab({
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.rdpPassword}
|
||||
onChange={(e) => setField("rdpPassword", e.target.value)}
|
||||
onChange={(e) =>
|
||||
setField("rdpPassword", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -256,6 +273,7 @@ export function HostEditorRdpTab({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@@ -515,12 +515,12 @@ export function HostItem({
|
||||
</button>
|
||||
)}
|
||||
{!selectionMode && !shouldUseClickTray && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
{selectionMode && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto shrink-0">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
@@ -529,7 +529,7 @@ export function HostItem({
|
||||
{/* Click-tray mode: always-visible action buttons */}
|
||||
{shouldUseClickTray && !selectionMode && (
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out ${isTrayOpen || isMenuOpen ? "max-h-[200px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out ${isTrayOpen || isMenuOpen ? "max-h-[72px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
@@ -730,7 +730,7 @@ export function HostItem({
|
||||
|
||||
{/* Hover tray (non-click-tray mode) */}
|
||||
{!shouldUseClickTray && !selectionMode && (
|
||||
<div className="max-h-0 opacity-0 overflow-hidden transition-all duration-150 ease-out group-hover:max-h-[200px] group-hover:opacity-100">
|
||||
<div className="max-h-0 opacity-0 overflow-hidden transition-all duration-150 ease-out group-hover:max-h-[72px] group-hover:opacity-100">
|
||||
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
@@ -1054,7 +1054,7 @@ export function HostItem({
|
||||
</div>
|
||||
|
||||
{/* Address — always visible */}
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none pl-3">
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
|
||||
@@ -1158,7 +1158,7 @@ export function HostItem({
|
||||
|
||||
{/* Action tray — slides open on hover (default) or via chevron in click-tray mode */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!shouldUseClickTray ? "group-hover:max-h-[300px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (shouldUseClickTray && isTrayOpen)) && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!shouldUseClickTray ? "group-hover:max-h-[130px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (shouldUseClickTray && isTrayOpen)) && !selectionMode ? "!max-h-[130px] !opacity-100" : ""}`}
|
||||
>
|
||||
{isOnline &&
|
||||
((host.cpu != null && host.cpu > 0) ||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
@@ -29,6 +29,7 @@ import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -150,6 +151,14 @@ function SnippetFormDialog({
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const folderMeta = useMemo(() => {
|
||||
const map = new Map<string, { color?: string; icon?: string }>();
|
||||
for (const f of folders) {
|
||||
map.set(f.name, { color: f.color ?? undefined, icon: f.icon });
|
||||
}
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(snippet?.name ?? "");
|
||||
@@ -238,20 +247,12 @@ function SnippetFormDialog({
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
<FolderPathPicker
|
||||
value={folder ?? ""}
|
||||
onChange={(e) =>
|
||||
setFolder(e.target.value === "" ? null : e.target.value)
|
||||
}
|
||||
className="px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("newUi.sidebar.snippets.noFolder")}</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.name}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(path) => setFolder(path === "" ? null : path)}
|
||||
folderPaths={folders.map((f) => f.name)}
|
||||
folderMeta={folderMeta}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
getUserRoles,
|
||||
saveUserPreferences,
|
||||
getUserPreferences,
|
||||
getConfiguredServerUrl,
|
||||
} from "@/main-axios";
|
||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
import {
|
||||
deleteWebAuthnCredential,
|
||||
listWebAuthnCredentials,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Copy,
|
||||
Database,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
@@ -86,6 +89,7 @@ type UserProfileSection =
|
||||
| "appearance"
|
||||
| "security"
|
||||
| "api-keys"
|
||||
| "data"
|
||||
| "c2s-tunnels";
|
||||
|
||||
const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
@@ -502,6 +506,11 @@ export function UserProfilePanel({
|
||||
const [deletePassword, setDeletePassword] = useState("");
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
// Data export/import
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
|
||||
// UI state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [newKeyOpen, setNewKeyOpen] = useState(false);
|
||||
@@ -1129,6 +1138,116 @@ export function UserProfilePanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportData() {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("export", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
const filename =
|
||||
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||
"termix-export.sqlite";
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success(t("newUi.sidebar.userProfile.exportSuccess"));
|
||||
} else {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
toast.error(
|
||||
err.error || t("newUi.sidebar.userProfile.exportFailed"),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.userProfile.exportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportData() {
|
||||
if (!importFile) {
|
||||
toast.error(t("newUi.sidebar.userProfile.importSelectFile"));
|
||||
return;
|
||||
}
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("import", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const s = result.summary;
|
||||
const total =
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.fileManagerItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
t("newUi.sidebar.userProfile.importCompleted", {
|
||||
total,
|
||||
skipped: s.skippedItems || 0,
|
||||
}),
|
||||
);
|
||||
setImportFile(null);
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: result.summary?.errors?.join(", ") || "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: err.error || "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canChangePasword = !isOidc || isDualAuth;
|
||||
|
||||
return (
|
||||
@@ -2310,6 +2429,80 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
id="data"
|
||||
label={t("newUi.sidebar.userProfile.sectionData")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
open={openSections.has("data")}
|
||||
onToggle={() => toggle("data")}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.exportData")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.exportDataDesc")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
|
||||
onClick={handleExportData}
|
||||
disabled={exportLoading}
|
||||
>
|
||||
{exportLoading
|
||||
? t("newUi.sidebar.userProfile.exporting")
|
||||
: t("newUi.sidebar.userProfile.export")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.importData")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{importFile
|
||||
? t("newUi.sidebar.userProfile.importDataSelected", {
|
||||
name: importFile.name,
|
||||
})
|
||||
: t("newUi.sidebar.userProfile.importDataDesc")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="pointer-events-none text-xs"
|
||||
>
|
||||
{importFile
|
||||
? t("newUi.sidebar.userProfile.changeFile")
|
||||
: t("newUi.sidebar.userProfile.selectFile")}
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleImportData}
|
||||
disabled={importLoading}
|
||||
>
|
||||
{importLoading
|
||||
? t("newUi.sidebar.userProfile.importing")
|
||||
: t("newUi.sidebar.userProfile.import")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{isElectron() && (
|
||||
<AccordionSection
|
||||
id="c2s-tunnels"
|
||||
|
||||
Reference in New Issue
Block a user