fix: general qol additions and new analytics/telemetrics feature

This commit is contained in:
LukeGus
2026-07-20 13:29:03 -05:00
parent 00c0fe7cab
commit c1c06272d7
38 changed files with 1701 additions and 606 deletions
+1
View File
@@ -43,6 +43,7 @@ on:
jobs: jobs:
build: build:
runs-on: blacksmith-8vcpu-ubuntu-2404 runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
+8
View File
@@ -293,6 +293,14 @@ networks:
<br /> <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 ## 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. 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
View File
@@ -58,7 +58,8 @@ WORKDIR /app
ENV DATA_DIR=/app/data \ ENV DATA_DIR=/app/data \
PORT=8080 \ 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 && \ RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
update-ca-certificates && \ update-ca-certificates && \
+9 -394
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "2.5.1", "version": "2.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "2.5.1", "version": "2.6.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
@@ -144,7 +144,7 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~7.0.2", "typescript": "~6.0.3",
"typescript-eslint": "^8.61.1", "typescript-eslint": "^8.61.1",
"vite": "^8.0.16", "vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
@@ -637,9 +637,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -657,9 +654,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -677,9 +671,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -697,9 +688,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6433,9 +6421,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6453,9 +6438,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6473,9 +6455,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6493,9 +6472,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7382,346 +7358,6 @@
"url": "https://opencollective.com/typescript-eslint" "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": { "node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.11", "version": "4.25.11",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz",
@@ -17625,38 +17261,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc" "tsc": "bin/tsc",
"tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=16.20.0" "node": ">=14.17"
},
"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_modules/typescript-eslint": { "node_modules/typescript-eslint": {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "termix", "name": "termix",
"private": true, "private": true,
"version": "2.5.1", "version": "2.6.0",
"description": "Self-hosted SSH and remote desktop management.", "description": "Self-hosted SSH and remote desktop management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
@@ -178,7 +178,7 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~7.0.2", "typescript": "~6.0.3",
"typescript-eslint": "^8.61.1", "typescript-eslint": "^8.61.1",
"vite": "^8.0.16", "vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
+1
View File
@@ -738,6 +738,7 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT"); addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER"); addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT"); addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
sqlite.exec(` sqlite.exec(`
CREATE TABLE IF NOT EXISTS dashboard_service_links ( CREATE TABLE IF NOT EXISTS dashboard_service_links (
+1
View File
@@ -816,6 +816,7 @@ export const userPreferences = sqliteTable("user_preferences", {
hiddenRailTabs: text("hidden_rail_tabs"), hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: integer("compact_host_view", { mode: "boolean" }), compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"), statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
+156 -2
View File
@@ -28,7 +28,7 @@ export type AcmeSettings = {
enabled: boolean; enabled: boolean;
domain: string; domain: string;
email: string; email: string;
challengeType: "http-webroot" | "dns-cloudflare"; challengeType: "http-webroot" | "dns-cloudflare" | "manual";
cloudflareToken: string; cloudflareToken: string;
lastIssuedAt: string | null; lastIssuedAt: string | null;
certStatus: "none" | "valid" | "expiring" | "expired"; certStatus: "none" | "valid" | "expiring" | "expired";
@@ -166,7 +166,7 @@ export function registerAcmeSSLRoutes(
* type: string * type: string
* challengeType: * challengeType:
* type: string * type: string
* enum: [http-webroot, dns-cloudflare] * enum: [http-webroot, dns-cloudflare, manual]
* cloudflareToken: * cloudflareToken:
* type: string * type: string
* responses: * responses:
@@ -414,4 +414,158 @@ export function registerAcmeSSLRoutes(
res.status(500).json({ error: `Certificate request failed: ${message}` }); 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}` });
}
});
} }
+6 -2
View File
@@ -1483,7 +1483,7 @@ router.get(
* name: field * name: field
* schema: * schema:
* type: string * type: string
* enum: [password, sudoPassword, vncPassword] * enum: [password, sudoPassword, vncPassword, key, keyPassword]
* responses: * responses:
* 200: * 200:
* description: The requested password value. * description: The requested password value.
@@ -1499,7 +1499,11 @@ router.get(
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const field = (req.query.field as string) || "password"; 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" }); return res.status(400).json({ error: "Invalid field" });
} }
@@ -33,6 +33,7 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
hiddenRailTabs: row?.hiddenRailTabs ?? null, hiddenRailTabs: row?.hiddenRailTabs ?? null,
compactHostView: row?.compactHostView ?? null, compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null, statusColorScheme: row?.statusColorScheme ?? null,
customThemes: row?.customThemes ?? null,
}); });
/** /**
@@ -106,6 +107,10 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
* statusColorScheme: * statusColorScheme:
* type: string * type: string
* nullable: true * 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) => { router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
@@ -175,6 +180,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean * type: boolean
* statusColorScheme: * statusColorScheme:
* type: string * type: string
* customThemes:
* type: string
* description: JSON-encoded array of the user's saved global custom terminal themes.
* responses: * responses:
* 200: * 200:
* description: Preferences updated successfully. * description: Preferences updated successfully.
@@ -201,6 +209,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
hiddenRailTabs, hiddenRailTabs,
compactHostView, compactHostView,
statusColorScheme, statusColorScheme,
customThemes,
} = req.body as { } = req.body as {
reopenTabsOnLogin?: boolean; reopenTabsOnLogin?: boolean;
theme?: string | null; theme?: string | null;
@@ -221,6 +230,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
hiddenRailTabs?: string | null; hiddenRailTabs?: string | null;
compactHostView?: boolean | null; compactHostView?: boolean | null;
statusColorScheme?: string | null; statusColorScheme?: string | null;
customThemes?: string | null;
}; };
const updates: UserPreferenceUpdate = { const updates: UserPreferenceUpdate = {
@@ -244,12 +254,41 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
storageMode, storageMode,
hiddenRailTabs, hiddenRailTabs,
statusColorScheme, statusColorScheme,
customThemes,
})) { })) {
if (value !== undefined && value !== null && typeof value !== "string") { if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a 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> = { const boolFields: Record<string, boolean | null | undefined> = {
commandAutocomplete, commandAutocomplete,
commandPaletteEnabled, commandPaletteEnabled,
@@ -294,6 +333,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (compactHostView !== undefined) updates.compactHostView = compactHostView; if (compactHostView !== undefined) updates.compactHostView = compactHostView;
if (statusColorScheme !== undefined) if (statusColorScheme !== undefined)
updates.statusColorScheme = statusColorScheme; updates.statusColorScheme = statusColorScheme;
if (customThemes !== undefined) updates.customThemes = customThemes;
if (Object.keys(updates).length === 1) { if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" }); 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 * @openapi
* /users/host-defaults: * /users/host-defaults:
+16
View File
@@ -166,6 +166,12 @@ router.post("/token", async (req, res) => {
* type: string * type: string
* enum: [rdp, vnc, telnet] * enum: [rdp, vnc, telnet]
* description: Override the host's default connection type * 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: * responses:
* 200: * 200:
* description: Connection token generated successfully * description: Connection token generated successfully
@@ -422,12 +428,22 @@ router.post(
let username: string; let username: string;
let password: string; let password: string;
const rdpAuthTypeForConnect = isSharedConnection
? null
: (host.rdpAuthType as string) ||
(host.rdpCredentialId ? "credential" : "direct");
switch (connectionType) { switch (connectionType) {
case "rdp": case "rdp":
if (rdpAuthTypeForConnect === "none") {
username = String(req.body?.promptedUsername || "");
password = String(req.body?.promptedPassword || "");
} else {
username = username =
(host.rdpUser as string) || (host.username as string) || ""; (host.rdpUser as string) || (host.username as string) || "";
password = password =
(host.rdpPassword as string) || (host.password as string) || ""; (host.rdpPassword as string) || (host.password as string) || "";
}
port = (host.rdpPort as number) || port || 3389; port = (host.rdpPort as number) || port || 3389;
break; break;
case "vnc": case "vnc":
+3
View File
@@ -170,6 +170,9 @@ import {
}); });
} }
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
startAnalyticsHeartbeat();
systemLogger.success("Termix backend started successfully", { systemLogger.success("Termix backend started successfully", {
operation: "backend_init_complete", operation: "backend_init_complete",
port: process.env.PORT || 4090, port: process.env.PORT || 4090,
@@ -47,6 +47,7 @@ describe("UserPreferenceRepository", () => {
hidden_rail_tabs TEXT, hidden_rail_tabs TEXT,
compact_host_view INTEGER, compact_host_view INTEGER,
status_color_scheme TEXT, status_color_scheme TEXT,
custom_themes TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
+145
View File
@@ -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,
}),
});
});
});
+132
View File
@@ -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
View File
@@ -207,7 +207,7 @@ export interface Host {
telnetUser?: string; telnetUser?: string;
telnetPassword?: string; telnetPassword?: string;
telnetCredentialId?: number | null; telnetCredentialId?: number | null;
rdpAuthType?: "direct" | "credential" | null; rdpAuthType?: "direct" | "credential" | "none" | null;
vncAuthType?: "direct" | "credential" | null; vncAuthType?: "direct" | "credential" | null;
telnetAuthType?: "direct" | "credential" | null; telnetAuthType?: "direct" | "credential" | null;
createdAt: string; createdAt: string;
@@ -329,7 +329,7 @@ export interface HostData {
telnetUser?: string; telnetUser?: string;
telnetPassword?: string; telnetPassword?: string;
telnetCredentialId?: number | null; telnetCredentialId?: number | null;
rdpAuthType?: "direct" | "credential" | null; rdpAuthType?: "direct" | "credential" | "none" | null;
vncAuthType?: "direct" | "credential" | null; vncAuthType?: "direct" | "credential" | null;
telnetAuthType?: "direct" | "credential" | null; telnetAuthType?: "direct" | "credential" | null;
} }
+1 -1
View File
@@ -152,7 +152,7 @@ export type Host = {
vncPort: number; vncPort: number;
telnetPort: number; telnetPort: number;
rdpAuthType?: "direct" | "credential"; rdpAuthType?: "direct" | "credential" | "none";
rdpCredentialId?: string; rdpCredentialId?: string;
rdpUser?: string; rdpUser?: string;
rdpPassword?: string; rdpPassword?: string;
+47 -5
View File
@@ -222,11 +222,14 @@ export function AppShell({
const [splitMode, setSplitMode] = useState<SplitMode>( const [splitMode, setSplitMode] = useState<SplitMode>(
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none", () => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
); );
const [paneTabIds, setPaneTabIds] = useState<(string | 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
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ?? // 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), Array(6).fill(null),
); );
const paneLayoutRestoredRef = useRef(false);
useEffect(() => { useEffect(() => {
paneTabIdsRef.current = paneTabIds; paneTabIdsRef.current = paneTabIds;
}, [paneTabIds]); }, [paneTabIds]);
@@ -262,8 +265,18 @@ export function AppShell({
}, [splitMode]); }, [splitMode]);
useEffect(() => { useEffect(() => {
localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds)); // Don't overwrite the saved layout with the empty initial state before
}, [paneTabIds]); // 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(); const isMobile = useIsMobile();
@@ -976,6 +989,35 @@ export function AppShell({
loadSavedTabs(); loadSavedTabs();
}, [hostsLoaded, userPrefsLoaded]); }, [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. // Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null, null,
+13 -1
View File
@@ -1,6 +1,6 @@
import { authApi, handleApiError } from "@/main-axios"; import { authApi, handleApiError } from "@/main-axios";
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare"; export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
export type AcmeSettings = { export type AcmeSettings = {
enabled: boolean; enabled: boolean;
@@ -45,3 +45,15 @@ export async function requestAcmeCertificate(): Promise<
handleApiError(error, "request ACME certificate"); 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");
}
}
+6 -1
View File
@@ -99,7 +99,12 @@ export async function adminDeleteUserHost(
export async function adminGetHostPassword( export async function adminGetHostPassword(
targetUserId: string, targetUserId: string,
hostId: number, hostId: number,
field: "password" | "sudoPassword" | "vncPassword" = "password", field:
| "password"
| "sudoPassword"
| "vncPassword"
| "key"
| "keyPassword" = "password",
): Promise<string | null> { ): Promise<string | null> {
try { try {
const response = await sshHostApi.get( const response = await sshHostApi.get(
+6 -1
View File
@@ -96,7 +96,12 @@ export async function getSSHHostWithCredentials(
export async function getHostPassword( export async function getHostPassword(
hostId: number, hostId: number,
field: "password" | "sudoPassword" | "vncPassword" = "password", field:
| "password"
| "sudoPassword"
| "vncPassword"
| "key"
| "keyPassword" = "password",
): Promise<string | null> { ): Promise<string | null> {
try { try {
const response = await sshHostApi.get( const response = await sshHostApi.get(
+10 -4
View File
@@ -208,12 +208,18 @@ export async function getGuacamoleToken(
export async function getGuacamoleTokenFromHost( export async function getGuacamoleTokenFromHost(
hostId: number, hostId: number,
protocol?: "rdp" | "vnc" | "telnet", protocol?: "rdp" | "vnc" | "telnet",
promptedCredentials?: { username?: string; password?: string },
): Promise<GuacamoleTokenResponse> { ): Promise<GuacamoleTokenResponse> {
try { try {
const response = await authApi.post( const response = await authApi.post(`/guacamole/connect-host/${hostId}`, {
`/guacamole/connect-host/${hostId}`, ...(protocol ? { protocol } : {}),
protocol ? { protocol } : {}, ...(promptedCredentials?.username
); ? { promptedUsername: promptedCredentials.username }
: {}),
...(promptedCredentials?.password
? { promptedPassword: promptedCredentials.password }
: {}),
});
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "get guacamole token from host"); throw handleApiError(error, "get guacamole token from host");
+18
View File
@@ -1,5 +1,6 @@
import { authApi } from "@/main-axios"; import { authApi } from "@/main-axios";
import { createTtlRequestCache } from "@/lib/ttl-request-cache"; import { createTtlRequestCache } from "@/lib/ttl-request-cache";
import type { TerminalTheme } from "@/lib/terminal-themes";
// OPEN TABS API // OPEN TABS API
// ============================================================================ // ============================================================================
@@ -82,6 +83,12 @@ export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
// USER PREFERENCES API // USER PREFERENCES API
// ============================================================================ // ============================================================================
export interface SavedCustomTheme {
id: string;
name: string;
colors: TerminalTheme["colors"];
}
export interface UserPreferences { export interface UserPreferences {
reopenTabsOnLogin: boolean; reopenTabsOnLogin: boolean;
theme?: string | null; theme?: string | null;
@@ -102,6 +109,17 @@ export interface UserPreferences {
hiddenRailTabs?: string | null; hiddenRailTabs?: string | null;
compactHostView?: boolean | null; compactHostView?: boolean | null;
statusColorScheme?: string | 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> { export async function getUserPreferences(): Promise<UserPreferences> {
+26
View File
@@ -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 // HOST DEFAULTS SETTINGS
// ============================================================================ // ============================================================================
+119 -12
View File
@@ -20,6 +20,15 @@ import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx"; import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { Button } from "@/components/button.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 { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import type { SSHHost } from "@/types"; import type { SSHHost } from "@/types";
@@ -97,7 +106,10 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
interface GuacamoleAppInnerProps { interface GuacamoleAppInnerProps {
hostId: number; hostId: number;
hostConfig: Pick<SSHHost, "connectionType" | "guacamoleConfig">; hostConfig: Pick<
SSHHost,
"connectionType" | "guacamoleConfig" | "rdpAuthType"
>;
hostName: string; hostName: string;
tabId?: string; tabId?: string;
protocol?: "rdp" | "vnc" | "telnet"; protocol?: "rdp" | "vnc" | "telnet";
@@ -123,12 +135,31 @@ const GuacamoleAppInner = React.forwardRef<
); );
const displayRef = useRef<GuacamoleDisplayHandle>(null); 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, () => ({ useImperativeHandle(ref, () => ({
disconnect: () => displayRef.current?.disconnect(), disconnect: () => displayRef.current?.disconnect(),
isConnected: () => displayRef.current?.isConnected() === true, isConnected: () => displayRef.current?.isConnected() === true,
})); }));
useEffect(() => { useEffect(() => {
if (needsCredentialPrompt && !promptedCredentials) {
setPromptOpen(true);
return;
}
setToken(null); setToken(null);
setError(null); setError(null);
getGuacdStatus() getGuacdStatus()
@@ -137,26 +168,44 @@ const GuacamoleAppInner = React.forwardRef<
setError(t("guacamole.guacdUnavailable")); setError(t("guacamole.guacdUnavailable"));
return; return;
} }
return getGuacamoleTokenFromHost(hostId, protocol); return getGuacamoleTokenFromHost(
hostId,
protocol,
promptedCredentials ?? undefined,
);
}) })
.then((result) => { .then((result) => {
if (result) { if (result) {
setToken(result.token); setToken(result.token);
const resolvedProtocol = (protocol ?? logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
hostConfig.connectionType ?? () => {},
"rdp") as "rdp" | "vnc" | "telnet"; );
logActivity(resolvedProtocol, hostId, hostName).catch(() => {});
} }
}) })
.catch((err) => setError(err?.message || t("guacamole.failedToConnect"))); .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(() => { const handleReconnect = useCallback(() => {
setConnectionError(null); setConnectionError(null);
setError(null); setError(null);
setToken(null); setToken(null);
if (needsCredentialPrompt) {
setPromptedCredentials(null);
setPromptUsername("");
setPromptPassword("");
setPromptOpen(true);
}
setRetryCount((c) => c + 1); setRetryCount((c) => c + 1);
}, []); }, [needsCredentialPrompt]);
useEffect(() => { useEffect(() => {
if (!tabId) return; if (!tabId) return;
@@ -169,6 +218,67 @@ const GuacamoleAppInner = React.forwardRef<
window.removeEventListener("termix:refresh-guacamole", handler); window.removeEventListener("termix:refresh-guacamole", handler);
}, [tabId, handleReconnect]); }, [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) { if (error) {
return ( return (
<div <div
@@ -216,10 +326,7 @@ const GuacamoleAppInner = React.forwardRef<
); );
} }
const resolvedProtocol = (protocol ?? hostConfig.connectionType) as const resolvedProtocol = resolvedProtocolForConnect;
| "rdp"
| "vnc"
| "telnet";
const configuredDpi = Number(hostConfig.guacamoleConfig?.dpi); const configuredDpi = Number(hostConfig.guacamoleConfig?.dpi);
return ( return (
+5 -5
View File
@@ -112,7 +112,7 @@
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.2115 0.0042 128.68); --secondary-foreground: oklch(0.2115 0.0042 128.68);
--muted: oklch(0.97 0 0); --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: oklch(0.97 0 0);
--accent-foreground: oklch(0.2115 0.0042 128.68); --accent-foreground: oklch(0.2115 0.0042 128.68);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
@@ -479,19 +479,19 @@
/* Font size scale applied to html element */ /* Font size scale applied to html element */
html.fs-xs { html.fs-xs {
font-size: 11px; font-size: 12px;
} }
html.fs-sm { html.fs-sm {
font-size: 12px; font-size: 13px;
} }
html.fs-md { html.fs-md {
font-size: 14px; font-size: 14px;
} }
html.fs-lg { html.fs-lg {
font-size: 16px; font-size: 17px;
} }
html.fs-xl { html.fs-xl {
font-size: 18px; font-size: 20px;
} }
@supports (padding-bottom: env(safe-area-inset-bottom)) { @supports (padding-bottom: env(safe-area-inset-bottom)) {
+47
View File
@@ -763,6 +763,14 @@
"customThemeBrightCyan": "Bright Cyan", "customThemeBrightCyan": "Bright Cyan",
"customThemeBrightWhite": "Bright White", "customThemeBrightWhite": "Bright White",
"customThemeResetTooltip": "Reset to defaults", "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", "syntaxHighlightingLabel": "Syntax Highlighting",
"syntaxHighlightingDesc": "Colorize terminal output (errors, paths, IPs, timestamps)", "syntaxHighlightingDesc": "Colorize terminal output (errors, paths, IPs, timestamps)",
"syntaxHighlightingCategories": "Highlight Categories", "syntaxHighlightingCategories": "Highlight Categories",
@@ -963,6 +971,9 @@
"failedToSaveCredential": "Failed to save credential", "failedToSaveCredential": "Failed to save credential",
"credentialNameRequired": "Please enter a name for the credential", "credentialNameRequired": "Please enter a name for the credential",
"credentialAuthRequired": "Add a password, an SSH key, or both", "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", "backToHosts": "Back to Hosts",
"backToCredentials": "Back to Credentials", "backToCredentials": "Back to Credentials",
"pinned": "Pinned", "pinned": "Pinned",
@@ -1252,6 +1263,8 @@
"authMethod": "Auth Method", "authMethod": "Auth Method",
"authTypeDirect": "Direct", "authTypeDirect": "Direct",
"authTypeCredential": "Credential", "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...", "selectCredential": "Select a credential...",
"connectionSettings": "Connection Settings", "connectionSettings": "Connection Settings",
"displaySettings": "Display Settings", "displaySettings": "Display Settings",
@@ -1446,6 +1459,9 @@
"reconnect": "Reconnect", "reconnect": "Reconnect",
"retry": "Retry", "retry": "Retry",
"guacdUnavailable": "Remote desktop service (guacd) is not available. Please ensure guacd is running and accessible and configured properly in admin settings.", "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", "ctrlAltDel": "Ctrl+Alt+Del",
"toolbar": { "toolbar": {
"ctrlAltDel": "Ctrl+Alt+Del", "ctrlAltDel": "Ctrl+Alt+Del",
@@ -2656,6 +2672,17 @@
"sslSaveFailed": "Failed to save SSL settings", "sslSaveFailed": "Failed to save SSL settings",
"sslRequiresDomain": "Domain and email are required", "sslRequiresDomain": "Domain and email are required",
"sslInfoNote": "After issuing a certificate, enable SSL in your environment variables (ENABLE_SSL=true) and restart Termix.", "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", "auditLogTotal": "{{total}} total entries",
"auditLogEmpty": "No audit log entries found", "auditLogEmpty": "No audit log entries found",
"auditLogSuccess": "Success", "auditLogSuccess": "Success",
@@ -2684,6 +2711,9 @@
"commandHistoryEnabled": "Command History", "commandHistoryEnabled": "Command History",
"commandHistoryEnabledDesc": "Allow command history recording. When disabled, history is not saved regardless of per-host settings.", "commandHistoryEnabledDesc": "Allow command history recording. When disabled, history is not saved regardless of per-host settings.",
"updateCommandHistoryFailed": "Failed to update command history setting", "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", "sessionTimeout": "Session Timeout",
"hours": "hours", "hours": "hours",
"sessionTimeoutRange": "Min 1h · Max 720h", "sessionTimeoutRange": "Min 1h · Max 720h",
@@ -3146,6 +3176,7 @@
"sectionAppearance": "Appearance", "sectionAppearance": "Appearance",
"sectionSecurity": "Security", "sectionSecurity": "Security",
"sectionApiKeys": "API Keys", "sectionApiKeys": "API Keys",
"sectionData": "Data",
"sectionC2sTunnels": "C2S Tunnels", "sectionC2sTunnels": "C2S Tunnels",
"usernameLabel": "Username", "usernameLabel": "Username",
"roleLabel": "Role", "roleLabel": "Role",
@@ -3240,6 +3271,22 @@
"apiKeyUsageHint": "Include your key in the", "apiKeyUsageHint": "Include your key in the",
"apiKeyUsageHintHeader": "header.", "apiKeyUsageHintHeader": "header.",
"apiKeyPermissionsHint": "Keys inherit the permissions of the creating user.", "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", "roleUser": "User",
"authMethodDual": "Dual Auth", "authMethodDual": "Dual Auth",
"authMethodOidc": "OIDC", "authMethodOidc": "OIDC",
+8 -5
View File
@@ -140,12 +140,15 @@ export function CommandPalette({
return () => window.removeEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, setIsOpen]); }, [isOpen, setIsOpen]);
const filteredHosts = hosts.filter( const filteredHosts = hosts.filter((h) => {
(h) => const query = search.toLowerCase();
h.name.toLowerCase().includes(search.toLowerCase()) || return (
h.ip.toLowerCase().includes(search.toLowerCase()) || h.name.toLowerCase().includes(query) ||
h.username.toLowerCase().includes(search.toLowerCase()), 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 // Group hosts by folder; ungrouped hosts appear first under an implicit root group
const groupedHosts: { folder: string | null; hosts: Host[] }[] = []; const groupedHosts: { folder: string | null; hosts: Host[] }[] = [];
+53
View File
@@ -41,12 +41,15 @@ import {
updateTailscaleSettings, updateTailscaleSettings,
getHostDefaults, getHostDefaults,
updateHostDefaults, updateHostDefaults,
getAnalyticsEnabled,
updateAnalyticsEnabled,
type HostDefaults, type HostDefaults,
} from "@/api/settings-api"; } from "@/api/settings-api";
import { import {
getAcmeSslSettings, getAcmeSslSettings,
updateAcmeSslSettings, updateAcmeSslSettings,
requestAcmeCertificate, requestAcmeCertificate,
uploadManualSslCertificate,
type AcmeSettings, type AcmeSettings,
} from "@/api/acme-ssl-api"; } from "@/api/acme-ssl-api";
import { import {
@@ -126,6 +129,7 @@ export function AdminSettingsPanel({
const [logLevel, setLogLevel] = useState("info"); const [logLevel, setLogLevel] = useState("info");
const [tailscaleApiKey, setTailscaleApiKey] = useState(""); const [tailscaleApiKey, setTailscaleApiKey] = useState("");
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true); const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({}); const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
// SSO / auto-provision state // SSO / auto-provision state
@@ -200,6 +204,9 @@ export function AdminSettingsPanel({
useState<AcmeSettings>(defaultAcmeSettings); useState<AcmeSettings>(defaultAcmeSettings);
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState(""); const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
const [acmeRequesting, setAcmeRequesting] = useState(false); const [acmeRequesting, setAcmeRequesting] = useState(false);
const [manualCertDraft, setManualCertDraft] = useState("");
const [manualKeyDraft, setManualKeyDraft] = useState("");
const [manualUploading, setManualUploading] = useState(false);
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]); const [sessions, setSessions] = useState<AdminSession[]>([]);
@@ -281,6 +288,7 @@ export function AdminSettingsPanel({
oidcSilent, oidcSilent,
tailscale, tailscale,
cmdHistory, cmdHistory,
analytics,
] = await Promise.allSettled([ ] = await Promise.allSettled([
getRegistrationAllowed(), getRegistrationAllowed(),
getPasswordLoginAllowed(), getPasswordLoginAllowed(),
@@ -293,6 +301,7 @@ export function AdminSettingsPanel({
getOidcSilentLoginDefault(), getOidcSilentLoginDefault(),
getTailscaleSettings(), getTailscaleSettings(),
getCommandHistoryEnabled(), getCommandHistoryEnabled(),
getAnalyticsEnabled(),
]); ]);
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed); if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
@@ -324,6 +333,9 @@ export function AdminSettingsPanel({
if (cmdHistory.status === "fulfilled") { if (cmdHistory.status === "fulfilled") {
setCommandHistoryEnabled(cmdHistory.value.enabled); setCommandHistoryEnabled(cmdHistory.value.enabled);
} }
if (analytics.status === "fulfilled") {
setAnalyticsEnabled(analytics.value.enabled);
}
} catch { } catch {
// non-fatal // 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() { async function handleSaveSessionTimeout() {
const hours = parseInt(sessionTimeout, 10); const hours = parseInt(sessionTimeout, 10);
if (isNaN(hours) || hours < 1 || hours > 720) { 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) { function handleProviderSaved(saved: SSOProvider) {
setSsoProviders((prev) => { setSsoProviders((prev) => {
const idx = prev.findIndex((p) => p.id === saved.id); const idx = prev.findIndex((p) => p.id === saved.id);
@@ -861,6 +906,8 @@ export function AdminSettingsPanel({
<AdminGeneralSettingsSection <AdminGeneralSettingsSection
open={openSections.has("general")} open={openSections.has("general")}
onToggle={() => toggle("general")} onToggle={() => toggle("general")}
analyticsEnabled={analyticsEnabled}
handleToggleAnalytics={handleToggleAnalytics}
allowRegistration={allowRegistration} allowRegistration={allowRegistration}
handleToggleRegistration={handleToggleRegistration} handleToggleRegistration={handleToggleRegistration}
allowPasswordLogin={allowPasswordLogin} allowPasswordLogin={allowPasswordLogin}
@@ -982,6 +1029,12 @@ export function AdminSettingsPanel({
requesting={acmeRequesting} requesting={acmeRequesting}
handleSave={handleSaveAcmeSettings} handleSave={handleSaveAcmeSettings}
handleRequest={handleRequestAcmeCertificate} handleRequest={handleRequestAcmeCertificate}
manualCertDraft={manualCertDraft}
setManualCertDraft={setManualCertDraft}
manualKeyDraft={manualKeyDraft}
setManualKeyDraft={setManualKeyDraft}
manualUploading={manualUploading}
handleManualUpload={handleManualSslUpload}
/> />
<AdminApiKeysSection <AdminApiKeysSection
+111 -30
View File
@@ -30,6 +30,8 @@ import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
type GeneralSettingsSectionProps = { type GeneralSettingsSectionProps = {
open: boolean; open: boolean;
onToggle: () => void; onToggle: () => void;
analyticsEnabled: boolean;
handleToggleAnalytics: () => void;
allowRegistration: boolean; allowRegistration: boolean;
handleToggleRegistration: () => void; handleToggleRegistration: () => void;
allowPasswordLogin: boolean; allowPasswordLogin: boolean;
@@ -67,6 +69,8 @@ type GeneralSettingsSectionProps = {
export function AdminGeneralSettingsSection({ export function AdminGeneralSettingsSection({
open, open,
onToggle, onToggle,
analyticsEnabled,
handleToggleAnalytics,
allowRegistration, allowRegistration,
handleToggleRegistration, handleToggleRegistration,
allowPasswordLogin, allowPasswordLogin,
@@ -110,6 +114,12 @@ export function AdminGeneralSettingsSection({
onToggle={onToggle} onToggle={onToggle}
> >
<div className="flex flex-col gap-0 pt-2"> <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 <SettingRow
label={t("admin.allowRegistration")} label={t("admin.allowRegistration")}
description={t("admin.allowRegistrationDesc")} description={t("admin.allowRegistrationDesc")}
@@ -1038,6 +1048,12 @@ type AdminSSLSectionProps = {
requesting: boolean; requesting: boolean;
handleSave: () => void; handleSave: () => void;
handleRequest: () => void; handleRequest: () => void;
manualCertDraft: string;
setManualCertDraft: Dispatch<SetStateAction<string>>;
manualKeyDraft: string;
setManualKeyDraft: Dispatch<SetStateAction<string>>;
manualUploading: boolean;
handleManualUpload: () => void;
}; };
export function AdminSSLSection({ export function AdminSSLSection({
@@ -1050,6 +1066,12 @@ export function AdminSSLSection({
requesting, requesting,
handleSave, handleSave,
handleRequest, handleRequest,
manualCertDraft,
setManualCertDraft,
manualKeyDraft,
setManualKeyDraft,
manualUploading,
handleManualUpload,
}: AdminSSLSectionProps) { }: AdminSSLSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -1107,6 +1129,41 @@ export function AdminSSLSection({
)} )}
</div> </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"> <div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest"> <label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslDomain")} {t("admin.sslDomain")}
@@ -1134,36 +1191,8 @@ export function AdminSSLSection({
className="text-xs" className="text-xs"
/> />
</div> </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" && ( {settings.challengeType === "dns-cloudflare" && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -1185,10 +1214,61 @@ export function AdminSSLSection({
</div> </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"> <span className="text-[10px] text-muted-foreground border-t border-border pt-2">
{t("admin.sslInfoNote")} {t("admin.sslInfoNote")}
</span> </span>
{settings.challengeType !== "manual" && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Button <Button
variant="outline" variant="outline"
@@ -1213,6 +1293,7 @@ export function AdminSSLSection({
: t("admin.sslRequestCert")} : t("admin.sslRequestCert")}
</Button> </Button>
</div> </div>
)}
</div> </div>
</AccordionSection> </AccordionSection>
); );
+4 -11
View File
@@ -17,6 +17,7 @@ import {
adminUpdateUserCredential, adminUpdateUserCredential,
} from "@/main-axios"; } from "@/main-axios";
import type { Credential } from "@/types/ui-types"; import type { Credential } from "@/types/ui-types";
import { FolderPathPicker } from "./FolderPathPicker";
type CredentialWithCertificate = Credential & { certPublicKey?: string }; type CredentialWithCertificate = Credential & { certPublicKey?: string };
@@ -151,19 +152,11 @@ export function CredentialEditorView({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.folder")} {t("hosts.folder")}
</label> </label>
<Input <FolderPathPicker
placeholder="e.g. Server Keys"
value={credForm.folder} value={credForm.folder}
onChange={(e) => setCredField("folder", e.target.value)} onChange={(path) => setCredField("folder", path)}
list="cred-folder-suggestions" folderPaths={existingFolders}
/> />
{existingFolders.length > 0 && (
<datalist id="cred-folder-suggestions">
{existingFolders.map((f) => (
<option key={f} value={f} />
))}
</datalist>
)}
</div> </div>
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
+265
View File
@@ -43,8 +43,16 @@ import {
adminUpdateUserHost, adminUpdateUserHost,
adminGetHostPassword, adminGetHostPassword,
adminGetUserSnippets, adminGetUserSnippets,
createCredential,
adminCreateUserCredential,
} from "@/main-axios"; } from "@/main-axios";
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api"; 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 { Host, VaultProfile } from "@/types/ui-types";
import type { SSHHost, TunnelStatus } from "@/types"; import type { SSHHost, TunnelStatus } from "@/types";
import { useTabsSafe } from "@/shell/TabContext"; import { useTabsSafe } from "@/shell/TabContext";
@@ -131,6 +139,66 @@ export function HostEditor({
const [isOidcUser, setIsOidcUser] = useState(false); const [isOidcUser, setIsOidcUser] = useState(false);
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]); const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
const [showVaultManager, setShowVaultManager] = useState(false); 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 = () => { const reloadVaultProfiles = () => {
getVaultProfiles() getVaultProfiles()
@@ -227,6 +295,77 @@ export function HostEditor({
(c) => c.id === form.credentialId, (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 // Shared hosts: view-level recipients see a read-only editor; edit-level
// recipients may change the host but never its credential/vault references // recipients may change the host but never its credential/vault references
// or auth type (owner-only, enforced server-side too). // or auth type (owner-only, enforced server-side too).
@@ -321,6 +460,20 @@ export function HostEditor({
<SectionCard <SectionCard
title={t("hosts.authenticationLabel")} title={t("hosts.authenticationLabel")}
icon={<Shield className="size-3.5" />} 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-4 py-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -1060,6 +1213,66 @@ export function HostEditor({
</div> </div>
{form.theme === "custom" && ( {form.theme === "custom" && (
<div className="flex flex-col gap-3"> <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"> <div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.customThemeColors")} {t("hosts.customThemeColors")}
@@ -2112,6 +2325,58 @@ export function HostEditor({
</Button> </Button>
)} )}
</div> </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> </div>
); );
} }
+2 -1
View File
@@ -205,7 +205,8 @@ export function createHostEditorForm(
rdpAuthType: (host?.rdpAuthType ?? rdpAuthType: (host?.rdpAuthType ??
(host?.rdpCredentialId ? "credential" : "direct")) as (host?.rdpCredentialId ? "credential" : "direct")) as
| "direct" | "direct"
| "credential", | "credential"
| "none",
vncAuthType: (host?.vncAuthType ?? vncAuthType: (host?.vncAuthType ??
(host?.vncCredentialId ? "credential" : "direct")) as (host?.vncCredentialId ? "credential" : "direct")) as
| "direct" | "direct"
+24 -6
View File
@@ -174,13 +174,20 @@ export function HostEditorRdpTab({
icon={<Shield className="size-3.5" />} icon={<Shield className="size-3.5" />}
> >
<div className="flex flex-col gap-4 py-3"> <div className="flex flex-col gap-4 py-3">
{credentials && credentials.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.authMethod")} {t("hosts.guac.authMethod")}
</label> </label>
<div className="flex gap-2"> <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 <button
key={m} key={m}
type="button" type="button"
@@ -198,7 +205,11 @@ export function HostEditorRdpTab({
))} ))}
</div> </div>
</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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{form.rdpAuthType === "credential" && {form.rdpAuthType === "credential" &&
credentials && credentials &&
@@ -209,10 +220,14 @@ export function HostEditorRdpTab({
</label> </label>
<select <select
value={form.rdpCredentialId} 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" 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) => ( {credentials.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name} {c.username ? `${c.name} (${c.username})` : c.name}
@@ -240,7 +255,9 @@ export function HostEditorRdpTab({
className="h-8 text-xs pr-8" className="h-8 text-xs pr-8"
placeholder="••••••••" placeholder="••••••••"
value={form.rdpPassword} value={form.rdpPassword}
onChange={(e) => setField("rdpPassword", e.target.value)} onChange={(e) =>
setField("rdpPassword", e.target.value)
}
/> />
</div> </div>
</> </>
@@ -256,6 +273,7 @@ export function HostEditorRdpTab({
/> />
</div> </div>
</div> </div>
)}
</div> </div>
</SectionCard> </SectionCard>
+6 -6
View File
@@ -515,12 +515,12 @@ export function HostItem({
</button> </button>
)} )}
{!selectionMode && !shouldUseClickTray && ( {!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} {host.ip}
</span> </span>
)} )}
{selectionMode && ( {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} {host.ip}
</span> </span>
)} )}
@@ -529,7 +529,7 @@ export function HostItem({
{/* Click-tray mode: always-visible action buttons */} {/* Click-tray mode: always-visible action buttons */}
{shouldUseClickTray && !selectionMode && ( {shouldUseClickTray && !selectionMode && (
<div <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"> <div className="flex items-center flex-wrap gap-1 px-2 pb-1">
{getSshActions(host).map(({ type, icon: Icon, label }) => ( {getSshActions(host).map(({ type, icon: Icon, label }) => (
@@ -730,7 +730,7 @@ export function HostItem({
{/* Hover tray (non-click-tray mode) */} {/* Hover tray (non-click-tray mode) */}
{!shouldUseClickTray && !selectionMode && ( {!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"> <div className="flex items-center flex-wrap gap-1 px-2 pb-1">
{getSshActions(host).map(({ type, icon: Icon, label }) => ( {getSshActions(host).map(({ type, icon: Icon, label }) => (
<button <button
@@ -1054,7 +1054,7 @@ export function HostItem({
</div> </div>
{/* Address — always visible */} {/* 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} {host.username}@{host.ip}
</span> </span>
@@ -1158,7 +1158,7 @@ export function HostItem({
{/* Action tray — slides open on hover (default) or via chevron in click-tray mode */} {/* Action tray — slides open on hover (default) or via chevron in click-tray mode */}
<div <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 && {isOnline &&
((host.cpu != null && host.cpu > 0) || ((host.cpu != null && host.cpu > 0) ||
+15 -14
View File
@@ -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 { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard"; import { copyToClipboard } from "@/lib/clipboard";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; 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 type { SSHHost } from "@/types/index";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import { FolderPathPicker } from "./FolderPathPicker";
import { Separator } from "@/components/separator"; import { Separator } from "@/components/separator";
import { import {
Dialog, Dialog,
@@ -150,6 +151,14 @@ function SnippetFormDialog({
new Set(), 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(() => { useEffect(() => {
if (open) { if (open) {
setName(snippet?.name ?? ""); setName(snippet?.name ?? "");
@@ -238,20 +247,12 @@ function SnippetFormDialog({
({t("newUi.sidebar.snippets.optional")}) ({t("newUi.sidebar.snippets.optional")})
</span> </span>
</label> </label>
<select <FolderPathPicker
value={folder ?? ""} value={folder ?? ""}
onChange={(e) => onChange={(path) => setFolder(path === "" ? null : path)}
setFolder(e.target.value === "" ? null : e.target.value) folderPaths={folders.map((f) => f.name)}
} folderMeta={folderMeta}
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>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold"> <label className="text-xs font-semibold">
+193
View File
@@ -16,7 +16,9 @@ import {
getUserRoles, getUserRoles,
saveUserPreferences, saveUserPreferences,
getUserPreferences, getUserPreferences,
getConfiguredServerUrl,
} from "@/main-axios"; } from "@/main-axios";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import { import {
deleteWebAuthnCredential, deleteWebAuthnCredential,
listWebAuthnCredentials, listWebAuthnCredentials,
@@ -45,6 +47,7 @@ import {
ChevronDown, ChevronDown,
Clock, Clock,
Copy, Copy,
Database,
Eye, Eye,
EyeOff, EyeOff,
Fingerprint, Fingerprint,
@@ -86,6 +89,7 @@ type UserProfileSection =
| "appearance" | "appearance"
| "security" | "security"
| "api-keys" | "api-keys"
| "data"
| "c2s-tunnels"; | "c2s-tunnels";
const THEMES: { id: ThemeId; preview: string }[] = [ const THEMES: { id: ThemeId; preview: string }[] = [
@@ -502,6 +506,11 @@ export function UserProfilePanel({
const [deletePassword, setDeletePassword] = useState(""); const [deletePassword, setDeletePassword] = useState("");
const [deleteLoading, setDeleteLoading] = useState(false); 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 // UI state
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [newKeyOpen, setNewKeyOpen] = 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; const canChangePasword = !isOidc || isDualAuth;
return ( return (
@@ -2310,6 +2429,80 @@ export function UserProfilePanel({
</div> </div>
</AccordionSection> </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() && ( {isElectron() && (
<AccordionSection <AccordionSection
id="c2s-tunnels" id="c2s-tunnels"