From c1c06272d7737d9f9a20e290e7f692fd5199d313 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 20 Jul 2026 13:29:03 -0500 Subject: [PATCH] fix: general qol additions and new analytics/telemetrics feature --- .github/workflows/docker.yml | 1 + README.md | 8 + docker/Dockerfile | 3 +- package-lock.json | 403 +----------------- package.json | 4 +- src/backend/database/db/index.ts | 1 + src/backend/database/db/schema.ts | 1 + .../database/routes/acme-ssl-routes.ts | 158 ++++++- src/backend/database/routes/host.ts | 8 +- .../database/routes/user-preferences.ts | 40 ++ .../database/routes/user-settings-routes.ts | 97 +++++ src/backend/hosts/guacamole/routes.ts | 24 +- src/backend/starter.ts | 3 + .../user-preference-repository.test.ts | 1 + src/backend/tests/utils/analytics.test.ts | 145 +++++++ src/backend/utils/analytics.ts | 132 ++++++ src/types/index.ts | 4 +- src/types/ui-types.ts | 2 +- src/ui/AppShell.tsx | 54 ++- src/ui/api/acme-ssl-api.ts | 14 +- src/ui/api/admin-user-data-api.ts | 7 +- src/ui/api/credentials-api.ts | 7 +- src/ui/api/guacamole-api.ts | 14 +- src/ui/api/open-tabs-api.ts | 18 + src/ui/api/settings-api.ts | 26 ++ src/ui/features/guacamole/GuacamoleApp.tsx | 131 +++++- src/ui/index.css | 10 +- src/ui/locales/en.json | 47 ++ src/ui/shell/CommandPalette.tsx | 15 +- src/ui/sidebar/AdminSettingsPanel.tsx | 53 +++ src/ui/sidebar/AdminSettingsSections.tsx | 185 +++++--- src/ui/sidebar/CredentialEditorView.tsx | 15 +- src/ui/sidebar/HostEditor.tsx | 265 ++++++++++++ src/ui/sidebar/HostEditorData.ts | 3 +- src/ui/sidebar/HostEditorGuacamoleTabs.tsx | 174 ++++---- src/ui/sidebar/SidebarTree.tsx | 12 +- src/ui/sidebar/SnippetsPanel.tsx | 29 +- src/ui/sidebar/UserProfilePanel.tsx | 193 +++++++++ 38 files changed, 1701 insertions(+), 606 deletions(-) create mode 100644 src/backend/tests/utils/analytics.test.ts create mode 100644 src/backend/utils/analytics.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 212f62cc..6a465bce 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -43,6 +43,7 @@ on: jobs: build: runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 20 steps: - name: Checkout repository uses: actions/checkout@v7 diff --git a/README.md b/README.md index 6ec99960..b9108069 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,14 @@ networks:
+## 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**. + +
+ ## 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. diff --git a/docker/Dockerfile b/docker/Dockerfile index f6ba5f51..f4028795 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -58,7 +58,8 @@ WORKDIR /app ENV DATA_DIR=/app/data \ PORT=8080 \ - NODE_ENV=production + NODE_ENV=production \ + POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \ update-ca-certificates && \ diff --git a/package-lock.json b/package-lock.json index 47534958..d9b4c673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "2.5.1", + "version": "2.6.0", "hasInstallScript": true, "dependencies": { "@simplewebauthn/browser": "^13.3.0", @@ -144,7 +144,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", - "typescript": "~7.0.2", + "typescript": "~6.0.3", "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-plugin-svgr": "^5.2.0", @@ -637,9 +637,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -657,9 +654,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -677,9 +671,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -697,9 +688,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -6433,9 +6421,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6453,9 +6438,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6473,9 +6455,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6493,9 +6472,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7382,346 +7358,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/@uiw/codemirror-extensions-basic-setup": { "version": "4.25.11", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", @@ -17625,38 +17261,17 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "node": ">=14.17" } }, "node_modules/typescript-eslint": { diff --git a/package.json b/package.json index 4e2a7ad4..7757ffae 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "2.5.1", + "version": "2.6.0", "description": "Self-hosted SSH and remote desktop management.", "author": "Karmaa", "main": "electron/main.cjs", @@ -178,7 +178,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", - "typescript": "~7.0.2", + "typescript": "~6.0.3", "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-plugin-svgr": "^5.2.0", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index b751654d..804f22f4 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -738,6 +738,7 @@ const migrateSchema = () => { addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT"); addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER"); addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT"); + addColumnIfNotExists("user_preferences", "custom_themes", "TEXT"); sqlite.exec(` CREATE TABLE IF NOT EXISTS dashboard_service_links ( diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index a3d99871..d7c46eca 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -816,6 +816,7 @@ export const userPreferences = sqliteTable("user_preferences", { hiddenRailTabs: text("hidden_rail_tabs"), compactHostView: integer("compact_host_view", { mode: "boolean" }), statusColorScheme: text("status_color_scheme"), + customThemes: text("custom_themes"), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts index 317a08a9..904d5af8 100644 --- a/src/backend/database/routes/acme-ssl-routes.ts +++ b/src/backend/database/routes/acme-ssl-routes.ts @@ -28,7 +28,7 @@ export type AcmeSettings = { enabled: boolean; domain: string; email: string; - challengeType: "http-webroot" | "dns-cloudflare"; + challengeType: "http-webroot" | "dns-cloudflare" | "manual"; cloudflareToken: string; lastIssuedAt: string | null; certStatus: "none" | "valid" | "expiring" | "expired"; @@ -166,7 +166,7 @@ export function registerAcmeSSLRoutes( * type: string * challengeType: * type: string - * enum: [http-webroot, dns-cloudflare] + * enum: [http-webroot, dns-cloudflare, manual] * cloudflareToken: * type: string * responses: @@ -414,4 +414,158 @@ export function registerAcmeSSLRoutes( res.status(500).json({ error: `Certificate request failed: ${message}` }); } }); + + /** + * @openapi + * /users/manual-ssl-upload: + * post: + * summary: Upload a manual/custom SSL certificate and key (admin only) + * description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * certificate: + * type: string + * privateKey: + * type: string + * responses: + * 200: + * description: Certificate uploaded and installed successfully. + * 400: + * description: Invalid or missing certificate/key. + * 403: + * description: Not authorized. + * 500: + * description: Certificate installation failed. + */ + router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const actor = await getAdminActor(userId); + try { + if (!actor) { + return res.status(403).json({ error: "Not authorized" }); + } + + const { certificate, privateKey } = req.body; + + if ( + typeof certificate !== "string" || + typeof privateKey !== "string" || + !certificate.includes("BEGIN CERTIFICATE") || + !privateKey.includes("PRIVATE KEY") + ) { + return res + .status(400) + .json({ error: "A valid PEM certificate and private key are required" }); + } + + await fs.mkdir(SSL_DIR, { recursive: true }); + + const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp"); + const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp"); + + try { + await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 }); + await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 }); + + try { + execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], { + stdio: "pipe", + }); + execFileSync( + "openssl", + ["pkey", "-in", tmpKeyFile, "-noout", "-check"], + { stdio: "pipe" }, + ); + } catch { + return res.status(400).json({ + error: "The provided certificate or private key is not valid PEM data", + }); + } + + const certPubkey = execFileSync( + "openssl", + ["x509", "-in", tmpCertFile, "-noout", "-pubkey"], + { stdio: "pipe" }, + ); + const keyPubkey = execFileSync( + "openssl", + ["pkey", "-in", tmpKeyFile, "-pubout"], + { stdio: "pipe" }, + ); + + if (!certPubkey.equals(keyPubkey)) { + return res + .status(400) + .json({ error: "The certificate and private key do not match" }); + } + + const certDest = path.join(SSL_DIR, "termix.crt"); + const keyDest = path.join(SSL_DIR, "termix.key"); + await fs.rename(tmpCertFile, certDest); + await fs.rename(tmpKeyFile, keyDest); + await fs.chmod(keyDest, 0o600); + await fs.chmod(certDest, 0o644); + } finally { + await fs.rm(tmpCertFile, { force: true }); + await fs.rm(tmpKeyFile, { force: true }); + } + + const settingsRepository = createCurrentSettingsRepository(); + const existing = await settingsRepository.get("acme_ssl_settings"); + const current = existing ? JSON.parse(existing) : {}; + const updated = { + ...current, + challengeType: "manual", + lastIssuedAt: new Date().toISOString(), + }; + await settingsRepository.set( + "acme_ssl_settings", + JSON.stringify(updated), + ); + + authLogger.info("Manual SSL certificate installed", { + operation: "manual_ssl_installed", + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: actor.username ?? userId, + action: "manual_ssl_upload", + resourceType: "setting", + details: JSON.stringify({ success: true }), + ipAddress, + userAgent, + success: true, + }); + + res.json({ success: true, ...(await getAcmeSettings()) }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + authLogger.error("Manual SSL certificate upload failed", err); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: actor?.username ?? userId, + action: "manual_ssl_upload", + resourceType: "setting", + details: JSON.stringify({ error: message }), + ipAddress, + userAgent, + success: false, + }); + + res + .status(500) + .json({ error: `Certificate installation failed: ${message}` }); + } + }); } diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 767e11be..68c77f15 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -1483,7 +1483,7 @@ router.get( * name: field * schema: * type: string - * enum: [password, sudoPassword, vncPassword] + * enum: [password, sudoPassword, vncPassword, key, keyPassword] * responses: * 200: * description: The requested password value. @@ -1499,7 +1499,11 @@ router.get( const userId = (req as AuthenticatedRequest).userId; const field = (req.query.field as string) || "password"; - if (!["password", "sudoPassword", "vncPassword"].includes(field)) { + if ( + !["password", "sudoPassword", "vncPassword", "key", "keyPassword"].includes( + field, + ) + ) { return res.status(400).json({ error: "Invalid field" }); } diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index 87b5ae73..b42d778c 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -33,6 +33,7 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({ hiddenRailTabs: row?.hiddenRailTabs ?? null, compactHostView: row?.compactHostView ?? null, statusColorScheme: row?.statusColorScheme ?? null, + customThemes: row?.customThemes ?? null, }); /** @@ -106,6 +107,10 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({ * statusColorScheme: * type: string * nullable: true + * customThemes: + * type: string + * nullable: true + * description: JSON-encoded array of the user's saved global custom terminal themes. */ router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -175,6 +180,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { * type: boolean * statusColorScheme: * type: string + * customThemes: + * type: string + * description: JSON-encoded array of the user's saved global custom terminal themes. * responses: * 200: * description: Preferences updated successfully. @@ -201,6 +209,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { hiddenRailTabs, compactHostView, statusColorScheme, + customThemes, } = req.body as { reopenTabsOnLogin?: boolean; theme?: string | null; @@ -221,6 +230,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { hiddenRailTabs?: string | null; compactHostView?: boolean | null; statusColorScheme?: string | null; + customThemes?: string | null; }; const updates: UserPreferenceUpdate = { @@ -244,12 +254,41 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { storageMode, hiddenRailTabs, statusColorScheme, + customThemes, })) { if (value !== undefined && value !== null && typeof value !== "string") { return res.status(400).json({ error: `${key} must be a string` }); } } + if (customThemes !== undefined && customThemes !== null) { + let parsedThemes: unknown; + try { + parsedThemes = JSON.parse(customThemes); + } catch { + return res + .status(400) + .json({ error: "customThemes must be a JSON-encoded array" }); + } + if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) { + return res.status(400).json({ + error: "customThemes must be a JSON array of at most 100 themes", + }); + } + const isValidTheme = (entry: unknown): boolean => + !!entry && + typeof entry === "object" && + typeof (entry as { id?: unknown }).id === "string" && + typeof (entry as { name?: unknown }).name === "string" && + !!(entry as { colors?: unknown }).colors && + typeof (entry as { colors?: unknown }).colors === "object"; + if (!parsedThemes.every(isValidTheme)) { + return res.status(400).json({ + error: "Each custom theme must have an id, name, and colors object", + }); + } + } + const boolFields: Record = { commandAutocomplete, commandPaletteEnabled, @@ -294,6 +333,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { if (compactHostView !== undefined) updates.compactHostView = compactHostView; if (statusColorScheme !== undefined) updates.statusColorScheme = statusColorScheme; + if (customThemes !== undefined) updates.customThemes = customThemes; if (Object.keys(updates).length === 1) { return res.status(400).json({ error: "No preferences provided" }); diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 2c23c107..bd0894bf 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -519,6 +519,103 @@ export function registerUserSettingsRoutes( }, ); + /** + * @openapi + * /users/analytics-enabled: + * get: + * summary: Get analytics enabled setting + * description: Returns whether anonymous usage telemetry is enabled. + * tags: + * - Users + * responses: + * 200: + * description: Analytics enabled status. + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + */ + router.get("/analytics-enabled", authenticateJWT, async (_req, res) => { + try { + res.json({ + enabled: await createCurrentSettingsRepository().getBoolean( + "analytics_enabled", + true, + ), + }); + } catch (err) { + authLogger.error("Failed to get analytics enabled setting", err); + res + .status(500) + .json({ error: "Failed to get analytics enabled setting" }); + } + }); + + /** + * @openapi + * /users/analytics-enabled: + * patch: + * summary: Update analytics enabled setting (admin only) + * description: Enables or disables the daily anonymous usage telemetry heartbeat. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * responses: + * 200: + * description: Setting updated. + * 403: + * description: Not authorized. + * 500: + * description: Failed to update setting. + */ + router.patch("/analytics-enabled", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const actor = await getAdminActor(userId); + if (!actor) { + return res.status(403).json({ error: "Not authorized" }); + } + const { enabled } = req.body; + if (typeof enabled !== "boolean") { + return res.status(400).json({ error: "enabled must be a boolean" }); + } + await createCurrentSettingsRepository().set( + "analytics_enabled", + enabled ? "true" : "false", + ); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: actor.username ?? userId, + action: "update_analytics_enabled", + resourceType: "setting", + details: JSON.stringify({ enabled }), + ipAddress, + userAgent, + success: true, + }); + + res.json({ enabled }); + } catch (err) { + authLogger.error("Failed to update analytics enabled setting", err); + res + .status(500) + .json({ error: "Failed to update analytics enabled setting" }); + } + }); + /** * @openapi * /users/host-defaults: diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index 443836fa..b106dab9 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -166,6 +166,12 @@ router.post("/token", async (req, res) => { * type: string * enum: [rdp, vnc, telnet] * description: Override the host's default connection type + * promptedUsername: + * type: string + * description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted. + * promptedPassword: + * type: string + * description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted. * responses: * 200: * description: Connection token generated successfully @@ -422,12 +428,22 @@ router.post( let username: string; let password: string; + const rdpAuthTypeForConnect = isSharedConnection + ? null + : (host.rdpAuthType as string) || + (host.rdpCredentialId ? "credential" : "direct"); + switch (connectionType) { case "rdp": - username = - (host.rdpUser as string) || (host.username as string) || ""; - password = - (host.rdpPassword as string) || (host.password as string) || ""; + if (rdpAuthTypeForConnect === "none") { + username = String(req.body?.promptedUsername || ""); + password = String(req.body?.promptedPassword || ""); + } else { + username = + (host.rdpUser as string) || (host.username as string) || ""; + password = + (host.rdpPassword as string) || (host.password as string) || ""; + } port = (host.rdpPort as number) || port || 3389; break; case "vnc": diff --git a/src/backend/starter.ts b/src/backend/starter.ts index dfc0a8f9..2eab71aa 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -170,6 +170,9 @@ import { }); } + const { startAnalyticsHeartbeat } = await import("./utils/analytics.js"); + startAnalyticsHeartbeat(); + systemLogger.success("Termix backend started successfully", { operation: "backend_init_complete", port: process.env.PORT || 4090, diff --git a/src/backend/tests/database/repositories/user-preference-repository.test.ts b/src/backend/tests/database/repositories/user-preference-repository.test.ts index 3ff56e19..e04002a2 100644 --- a/src/backend/tests/database/repositories/user-preference-repository.test.ts +++ b/src/backend/tests/database/repositories/user-preference-repository.test.ts @@ -47,6 +47,7 @@ describe("UserPreferenceRepository", () => { hidden_rail_tabs TEXT, compact_host_view INTEGER, status_color_scheme TEXT, + custom_themes TEXT, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/src/backend/tests/utils/analytics.test.ts b/src/backend/tests/utils/analytics.test.ts new file mode 100644 index 00000000..83c8f8de --- /dev/null +++ b/src/backend/tests/utils/analytics.test.ts @@ -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 = {}; + const methods = ["from", "where", "groupBy"]; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + (chain as unknown as Promise).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, + }), + }); + }); +}); diff --git a/src/backend/utils/analytics.ts b/src/backend/utils/analytics.ts new file mode 100644 index 00000000..c77c4360 --- /dev/null +++ b/src/backend/utils/analytics.ts @@ -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 { + return createCurrentSettingsRepository().getBoolean("analytics_enabled", true); +} + +export async function getOrCreateInstanceId(): Promise { + 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> { + const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString(); + const db = createCurrentRepositoryContext().drizzle; + + const rows = await db + .select({ + type: recentActivity.type, + count: sql`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 = {}; + 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`count(*)` }).from(users), + db.select({ count: sql`count(*)` }).from(hosts), + ]); + + return { + userCount: Number(userRows[0]?.count ?? 0), + hostCount: Number(hostRows[0]?.count ?? 0), + }; +} + +export async function collectAndSendHeartbeat(): Promise { + 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); +} diff --git a/src/types/index.ts b/src/types/index.ts index 65e4dcbe..13455b45 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -207,7 +207,7 @@ export interface Host { telnetUser?: string; telnetPassword?: string; telnetCredentialId?: number | null; - rdpAuthType?: "direct" | "credential" | null; + rdpAuthType?: "direct" | "credential" | "none" | null; vncAuthType?: "direct" | "credential" | null; telnetAuthType?: "direct" | "credential" | null; createdAt: string; @@ -329,7 +329,7 @@ export interface HostData { telnetUser?: string; telnetPassword?: string; telnetCredentialId?: number | null; - rdpAuthType?: "direct" | "credential" | null; + rdpAuthType?: "direct" | "credential" | "none" | null; vncAuthType?: "direct" | "credential" | null; telnetAuthType?: "direct" | "credential" | null; } diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 9cf65840..67e6c45a 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -152,7 +152,7 @@ export type Host = { vncPort: number; telnetPort: number; - rdpAuthType?: "direct" | "credential"; + rdpAuthType?: "direct" | "credential" | "none"; rdpCredentialId?: string; rdpUser?: string; rdpPassword?: string; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 0c866d87..029060ec 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -222,11 +222,14 @@ export function AppShell({ const [splitMode, setSplitMode] = useState( () => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none", ); - const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>( - () => - JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ?? - Array(6).fill(null), + // paneTabIds holds live tab.id values, which change on every restore, so we + // can't restore it from storage directly. It starts empty and gets filled in + // once by the reconciliation effect below, keyed off the stable instanceId + // values saved in termix_paneInstanceIds. + const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() => + Array(6).fill(null), ); + const paneLayoutRestoredRef = useRef(false); useEffect(() => { paneTabIdsRef.current = paneTabIds; }, [paneTabIds]); @@ -262,8 +265,18 @@ export function AppShell({ }, [splitMode]); useEffect(() => { - localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds)); - }, [paneTabIds]); + // Don't overwrite the saved layout with the empty initial state before + // reconciliation has had a chance to restore it. + if (!paneLayoutRestoredRef.current) return; + const instanceIds = paneTabIds.map((id) => { + if (id == null) return null; + return tabs.find((t) => t.id === id)?.instanceId ?? null; + }); + localStorage.setItem( + "termix_paneInstanceIds", + JSON.stringify(instanceIds), + ); + }, [paneTabIds, tabs]); const isMobile = useIsMobile(); @@ -976,6 +989,35 @@ export function AppShell({ loadSavedTabs(); }, [hostsLoaded, userPrefsLoaded]); + // Restore split-screen pane assignments once tabs are settled. Saved assignments are + // keyed by instanceId (stable across reloads) and remapped to the live tab.id here, + // since tab.id is regenerated every time a tab is (re)opened. + useEffect(() => { + if (!tabsReady || paneLayoutRestoredRef.current) return; + paneLayoutRestoredRef.current = true; + + try { + const savedInstanceIds: (string | null)[] = JSON.parse( + localStorage.getItem("termix_paneInstanceIds") ?? "null", + ); + if (!Array.isArray(savedInstanceIds)) return; + + const restored = savedInstanceIds.map((instanceId) => { + if (instanceId == null) return null; + return tabs.find((t) => t.instanceId === instanceId)?.id ?? null; + }); + if (restored.some((id) => id != null)) { + setPaneTabIds(restored); + } else { + // None of the saved panes could be restored (e.g. reopen-tabs-on-login + // is disabled), so drop back to a single view instead of an empty split. + setSplitMode("none"); + } + } catch { + // silently fail + } + }, [tabsReady, tabs]); + // Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB. const orderSyncTimeoutRef = useRef | null>( null, diff --git a/src/ui/api/acme-ssl-api.ts b/src/ui/api/acme-ssl-api.ts index 64bb286a..c9e9234d 100644 --- a/src/ui/api/acme-ssl-api.ts +++ b/src/ui/api/acme-ssl-api.ts @@ -1,6 +1,6 @@ import { authApi, handleApiError } from "@/main-axios"; -export type AcmeChallengeType = "http-webroot" | "dns-cloudflare"; +export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual"; export type AcmeSettings = { enabled: boolean; @@ -45,3 +45,15 @@ export async function requestAcmeCertificate(): Promise< handleApiError(error, "request ACME certificate"); } } + +export async function uploadManualSslCertificate(payload: { + certificate: string; + privateKey: string; +}): Promise { + try { + const response = await authApi.post("/users/manual-ssl-upload", payload); + return response.data; + } catch (error) { + handleApiError(error, "upload manual SSL certificate"); + } +} diff --git a/src/ui/api/admin-user-data-api.ts b/src/ui/api/admin-user-data-api.ts index ecac34ea..ff2761b7 100644 --- a/src/ui/api/admin-user-data-api.ts +++ b/src/ui/api/admin-user-data-api.ts @@ -99,7 +99,12 @@ export async function adminDeleteUserHost( export async function adminGetHostPassword( targetUserId: string, hostId: number, - field: "password" | "sudoPassword" | "vncPassword" = "password", + field: + | "password" + | "sudoPassword" + | "vncPassword" + | "key" + | "keyPassword" = "password", ): Promise { try { const response = await sshHostApi.get( diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts index 01e7bcd7..52fbb3ac 100644 --- a/src/ui/api/credentials-api.ts +++ b/src/ui/api/credentials-api.ts @@ -96,7 +96,12 @@ export async function getSSHHostWithCredentials( export async function getHostPassword( hostId: number, - field: "password" | "sudoPassword" | "vncPassword" = "password", + field: + | "password" + | "sudoPassword" + | "vncPassword" + | "key" + | "keyPassword" = "password", ): Promise { try { const response = await sshHostApi.get( diff --git a/src/ui/api/guacamole-api.ts b/src/ui/api/guacamole-api.ts index abb59e60..368501d1 100644 --- a/src/ui/api/guacamole-api.ts +++ b/src/ui/api/guacamole-api.ts @@ -208,12 +208,18 @@ export async function getGuacamoleToken( export async function getGuacamoleTokenFromHost( hostId: number, protocol?: "rdp" | "vnc" | "telnet", + promptedCredentials?: { username?: string; password?: string }, ): Promise { try { - const response = await authApi.post( - `/guacamole/connect-host/${hostId}`, - protocol ? { protocol } : {}, - ); + const response = await authApi.post(`/guacamole/connect-host/${hostId}`, { + ...(protocol ? { protocol } : {}), + ...(promptedCredentials?.username + ? { promptedUsername: promptedCredentials.username } + : {}), + ...(promptedCredentials?.password + ? { promptedPassword: promptedCredentials.password } + : {}), + }); return response.data; } catch (error) { throw handleApiError(error, "get guacamole token from host"); diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index e9eb5a7c..dbcf7429 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -1,5 +1,6 @@ import { authApi } from "@/main-axios"; import { createTtlRequestCache } from "@/lib/ttl-request-cache"; +import type { TerminalTheme } from "@/lib/terminal-themes"; // OPEN TABS API // ============================================================================ @@ -82,6 +83,12 @@ export async function getActiveSessions(): Promise { // USER PREFERENCES API // ============================================================================ +export interface SavedCustomTheme { + id: string; + name: string; + colors: TerminalTheme["colors"]; +} + export interface UserPreferences { reopenTabsOnLogin: boolean; theme?: string | null; @@ -102,6 +109,17 @@ export interface UserPreferences { hiddenRailTabs?: string | null; compactHostView?: boolean | null; statusColorScheme?: string | null; + customThemes?: string | null; +} + +export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } } export async function getUserPreferences(): Promise { diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index 4eeed208..f49f8ff4 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -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 // ============================================================================ diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index 93865a14..47659b07 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -20,6 +20,15 @@ import { useTranslation } from "react-i18next"; import { AlertCircle, RefreshCw } from "lucide-react"; import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx"; import { Button } from "@/components/button.tsx"; +import { Input } from "@/components/input.tsx"; +import { PasswordInput } from "@/components/password-input.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/dialog.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import type { SSHHost } from "@/types"; @@ -97,7 +106,10 @@ const GuacamoleApp = React.forwardRef( interface GuacamoleAppInnerProps { hostId: number; - hostConfig: Pick; + hostConfig: Pick< + SSHHost, + "connectionType" | "guacamoleConfig" | "rdpAuthType" + >; hostName: string; tabId?: string; protocol?: "rdp" | "vnc" | "telnet"; @@ -123,12 +135,31 @@ const GuacamoleAppInner = React.forwardRef< ); const displayRef = useRef(null); + const resolvedProtocolForConnect = (protocol ?? + hostConfig.connectionType ?? + "rdp") as "rdp" | "vnc" | "telnet"; + const needsCredentialPrompt = + resolvedProtocolForConnect === "rdp" && hostConfig.rdpAuthType === "none"; + + const [promptedCredentials, setPromptedCredentials] = useState<{ + username: string; + password: string; + } | null>(null); + const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt); + const [promptUsername, setPromptUsername] = useState(""); + const [promptPassword, setPromptPassword] = useState(""); + useImperativeHandle(ref, () => ({ disconnect: () => displayRef.current?.disconnect(), isConnected: () => displayRef.current?.isConnected() === true, })); useEffect(() => { + if (needsCredentialPrompt && !promptedCredentials) { + setPromptOpen(true); + return; + } + setToken(null); setError(null); getGuacdStatus() @@ -137,26 +168,44 @@ const GuacamoleAppInner = React.forwardRef< setError(t("guacamole.guacdUnavailable")); return; } - return getGuacamoleTokenFromHost(hostId, protocol); + return getGuacamoleTokenFromHost( + hostId, + protocol, + promptedCredentials ?? undefined, + ); }) .then((result) => { if (result) { setToken(result.token); - const resolvedProtocol = (protocol ?? - hostConfig.connectionType ?? - "rdp") as "rdp" | "vnc" | "telnet"; - logActivity(resolvedProtocol, hostId, hostName).catch(() => {}); + logActivity(resolvedProtocolForConnect, hostId, hostName).catch( + () => {}, + ); } }) .catch((err) => setError(err?.message || t("guacamole.failedToConnect"))); - }, [hostConfig.connectionType, hostId, hostName, protocol, retryCount, t]); + }, [ + hostId, + hostName, + protocol, + retryCount, + t, + needsCredentialPrompt, + promptedCredentials, + resolvedProtocolForConnect, + ]); const handleReconnect = useCallback(() => { setConnectionError(null); setError(null); setToken(null); + if (needsCredentialPrompt) { + setPromptedCredentials(null); + setPromptUsername(""); + setPromptPassword(""); + setPromptOpen(true); + } setRetryCount((c) => c + 1); - }, []); + }, [needsCredentialPrompt]); useEffect(() => { if (!tabId) return; @@ -169,6 +218,67 @@ const GuacamoleAppInner = React.forwardRef< window.removeEventListener("termix:refresh-guacamole", handler); }, [tabId, handleReconnect]); + if (promptOpen) { + return ( + { + if (!open) setPromptOpen(false); + }} + > + + + + {t("guacamole.credentialPromptTitle")} + + + {t("guacamole.credentialPromptDescription")} + + +
{ + e.preventDefault(); + setPromptedCredentials({ + username: promptUsername, + password: promptPassword, + }); + setPromptOpen(false); + }} + > +
+ + setPromptUsername(e.target.value)} + /> +
+
+ + setPromptPassword(e.target.value)} + /> +
+
+ +
+
+
+
+ ); + } + if (error) { return (
window.removeEventListener("keydown", handleKeyDown, true); }, [isOpen, setIsOpen]); - const filteredHosts = hosts.filter( - (h) => - h.name.toLowerCase().includes(search.toLowerCase()) || - h.ip.toLowerCase().includes(search.toLowerCase()) || - h.username.toLowerCase().includes(search.toLowerCase()), - ); + const filteredHosts = hosts.filter((h) => { + const query = search.toLowerCase(); + return ( + h.name.toLowerCase().includes(query) || + h.ip.toLowerCase().includes(query) || + h.username.toLowerCase().includes(query) || + h.tags?.some((tag) => tag.toLowerCase().includes(query)) + ); + }); // Group hosts by folder; ungrouped hosts appear first under an implicit root group const groupedHosts: { folder: string | null; hosts: Host[] }[] = []; diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index a44bffe1..3468ac7f 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -41,12 +41,15 @@ import { updateTailscaleSettings, getHostDefaults, updateHostDefaults, + getAnalyticsEnabled, + updateAnalyticsEnabled, type HostDefaults, } from "@/api/settings-api"; import { getAcmeSslSettings, updateAcmeSslSettings, requestAcmeCertificate, + uploadManualSslCertificate, type AcmeSettings, } from "@/api/acme-ssl-api"; import { @@ -126,6 +129,7 @@ export function AdminSettingsPanel({ const [logLevel, setLogLevel] = useState("info"); const [tailscaleApiKey, setTailscaleApiKey] = useState(""); const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true); + const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [hostDefaults, setHostDefaults] = useState({}); // SSO / auto-provision state @@ -200,6 +204,9 @@ export function AdminSettingsPanel({ useState(defaultAcmeSettings); const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState(""); const [acmeRequesting, setAcmeRequesting] = useState(false); + const [manualCertDraft, setManualCertDraft] = useState(""); + const [manualKeyDraft, setManualKeyDraft] = useState(""); + const [manualUploading, setManualUploading] = useState(false); const [users, setUsers] = useState([]); const [sessions, setSessions] = useState([]); @@ -281,6 +288,7 @@ export function AdminSettingsPanel({ oidcSilent, tailscale, cmdHistory, + analytics, ] = await Promise.allSettled([ getRegistrationAllowed(), getPasswordLoginAllowed(), @@ -293,6 +301,7 @@ export function AdminSettingsPanel({ getOidcSilentLoginDefault(), getTailscaleSettings(), getCommandHistoryEnabled(), + getAnalyticsEnabled(), ]); if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed); @@ -324,6 +333,9 @@ export function AdminSettingsPanel({ if (cmdHistory.status === "fulfilled") { setCommandHistoryEnabled(cmdHistory.value.enabled); } + if (analytics.status === "fulfilled") { + setAnalyticsEnabled(analytics.value.enabled); + } } catch { // non-fatal } @@ -431,6 +443,17 @@ export function AdminSettingsPanel({ } } + async function handleToggleAnalytics() { + const newVal = !analyticsEnabled; + setAnalyticsEnabled(newVal); + try { + await updateAnalyticsEnabled(newVal); + } catch { + setAnalyticsEnabled(!newVal); + toast.error(t("admin.updateAnalyticsFailed")); + } + } + async function handleSaveSessionTimeout() { const hours = parseInt(sessionTimeout, 10); if (isNaN(hours) || hours < 1 || hours > 720) { @@ -592,6 +615,28 @@ export function AdminSettingsPanel({ } } + async function handleManualSslUpload() { + if (!manualCertDraft.trim() || !manualKeyDraft.trim()) { + toast.error(t("admin.sslManualRequiresFields")); + return; + } + setManualUploading(true); + try { + const result = await uploadManualSslCertificate({ + certificate: manualCertDraft, + privateKey: manualKeyDraft, + }); + setAcmeSettings(result); + setManualCertDraft(""); + setManualKeyDraft(""); + toast.success(t("admin.sslManualUploadSuccess")); + } catch (e) { + toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed"))); + } finally { + setManualUploading(false); + } + } + function handleProviderSaved(saved: SSOProvider) { setSsoProviders((prev) => { const idx = prev.findIndex((p) => p.id === saved.id); @@ -861,6 +906,8 @@ export function AdminSettingsPanel({ toggle("general")} + analyticsEnabled={analyticsEnabled} + handleToggleAnalytics={handleToggleAnalytics} allowRegistration={allowRegistration} handleToggleRegistration={handleToggleRegistration} allowPasswordLogin={allowPasswordLogin} @@ -982,6 +1029,12 @@ export function AdminSettingsPanel({ requesting={acmeRequesting} handleSave={handleSaveAcmeSettings} handleRequest={handleRequestAcmeCertificate} + manualCertDraft={manualCertDraft} + setManualCertDraft={setManualCertDraft} + manualKeyDraft={manualKeyDraft} + setManualKeyDraft={setManualKeyDraft} + manualUploading={manualUploading} + handleManualUpload={handleManualSslUpload} /> void; + analyticsEnabled: boolean; + handleToggleAnalytics: () => void; allowRegistration: boolean; handleToggleRegistration: () => void; allowPasswordLogin: boolean; @@ -67,6 +69,8 @@ type GeneralSettingsSectionProps = { export function AdminGeneralSettingsSection({ open, onToggle, + analyticsEnabled, + handleToggleAnalytics, allowRegistration, handleToggleRegistration, allowPasswordLogin, @@ -110,6 +114,12 @@ export function AdminGeneralSettingsSection({ onToggle={onToggle} >
+ + + void; handleRequest: () => void; + manualCertDraft: string; + setManualCertDraft: Dispatch>; + manualKeyDraft: string; + setManualKeyDraft: Dispatch>; + manualUploading: boolean; + handleManualUpload: () => void; }; export function AdminSSLSection({ @@ -1050,6 +1066,12 @@ export function AdminSSLSection({ requesting, handleSave, handleRequest, + manualCertDraft, + setManualCertDraft, + manualKeyDraft, + setManualKeyDraft, + manualUploading, + handleManualUpload, }: AdminSSLSectionProps) { const { t } = useTranslation(); @@ -1107,34 +1129,6 @@ export function AdminSSLSection({ )}
-
- - - setSettings((p) => ({ ...p, domain: e.target.value })) - } - placeholder={t("admin.sslDomainPlaceholder")} - className="text-xs" - /> -
- -
- - - setSettings((p) => ({ ...p, email: e.target.value })) - } - placeholder={t("admin.sslEmailPlaceholder")} - className="text-xs" - /> -
-
+ {settings.challengeType !== "manual" && ( + <> +
+ + + setSettings((p) => ({ ...p, domain: e.target.value })) + } + placeholder={t("admin.sslDomainPlaceholder")} + className="text-xs" + /> +
+ +
+ + + setSettings((p) => ({ ...p, email: e.target.value })) + } + placeholder={t("admin.sslEmailPlaceholder")} + className="text-xs" + /> +
+ + )} + {settings.challengeType === "dns-cloudflare" && (
)} + {settings.challengeType === "manual" && ( + <> +
+ +