From e53e3a363a7d4bb543a5450166e541978cec681f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:42:16 -0500 Subject: [PATCH 01/17] chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910) Bumps the dev-minor-updates group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` | | [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` | | [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` | | [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` | | [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` | | [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` | Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `cytoscape` from 3.33.4 to 3.34.0 - [Release notes](https://github.com/cytoscape/cytoscape.js/releases) - [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0) Updates `electron` from 42.2.0 to 42.4.1 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1) Updates `electron-builder` from 26.8.1 to 26.15.3 - [Release notes](https://github.com/electron-userland/electron-builder/releases) - [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md) - [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder) Updates `lucide-react` from 1.16.0 to 1.20.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react) Updates `radix-ui` from 1.4.3 to 1.6.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-hook-form` from 7.76.1 to 7.79.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0) Updates `sharp` from 0.34.5 to 0.35.1 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1) Updates `typescript-eslint` from 8.60.0 to 8.61.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: cytoscape dependency-version: 3.34.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron dependency-version: 42.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron-builder dependency-version: 26.15.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: lucide-react dependency-version: 1.18.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: radix-ui dependency-version: 1.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-hook-form dependency-version: 7.79.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: sharp dependency-version: 0.35.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 5825 +++++++++++---------------------------------- package.json | 24 +- 2 files changed, 1374 insertions(+), 4475 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b918430e..41fe6b0bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,11 +59,11 @@ "@radix-ui/react-popover": "^1.1.16", "@radix-ui/react-progress": "^1.1.9", "@radix-ui/react-scroll-area": "^1.2.11", - "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-select": "^2.3.1", "@radix-ui/react-separator": "^1.1.9", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-slider": "^1.4.1", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-switch": "^1.3.1", "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", "@tailwindcss/vite": "^4.2.4", @@ -101,9 +101,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "concurrently": "^9.2.1", - "cytoscape": "^3.33.2", - "electron": "^42.2.0", - "electron-builder": "^26.8.1", + "cytoscape": "^3.34.0", + "electron": "^42.4.1", + "electron-builder": "^26.15.3", "eslint": "^9.0.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -115,14 +115,14 @@ "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.1.1", "lint-staged": "^17.0.7", - "lucide-react": "^1.11.0", + "lucide-react": "^1.20.0", "prettier": "3.8.3", - "radix-ui": "^1.4.3", + "radix-ui": "^1.6.0", "react": "^19.2.7", "react-cytoscapejs": "^2.0.0", "react-dom": "^19.2.7", "react-h5-audio-player": "^3.10.2", - "react-hook-form": "^7.73.1", + "react-hook-form": "^7.79.0", "react-i18next": "^17.0.4", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", @@ -131,13 +131,13 @@ "react-syntax-highlighter": "^16.1.1", "react-xtermjs": "^1.0.10", "remark-gfm": "^4.0.1", - "sharp": "^0.34.5", + "sharp": "^0.35.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", "typescript": "~6.0.3", - "typescript-eslint": "^8.59.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-plugin-svgr": "^5.2.0", "vitest": "^4.1.8" @@ -1482,48 +1482,16 @@ "node": ">=20.0.0" } }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", + "integrity": "sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ==", "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@develar/schema-utils/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "node": ">=22.12.0" } }, - "node_modules/@develar/schema-utils/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@electron/asar": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz", @@ -2247,9 +2215,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.1.tgz", + "integrity": "sha512-T15JRWOubQ3f5+GxnWeIvo47u5qV0M9HBgJhT+f2gE1e9e6OhR6K73Re52Hm80qWcu1DNb3GweKmpr/MnuP2Ow==", "cpu": [ "arm64" ], @@ -2260,19 +2228,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.0" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.1.tgz", + "integrity": "sha512-t1CPD0cr7XCHjwUj6tQ5MC0pCi866I+gUW6zbUX4aFPnKd1DFBtk0M+gWcjX8VeEzgfCNiSiNTVFZ6b7kvdbnQ==", "cpu": [ "x64" ], @@ -2283,19 +2251,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.0" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.1.tgz", + "integrity": "sha512-MBSQXqNPThW9EcZ905H6N4sEdX5EwZEYzGx5EBq9ncDCGJALMiY1xPFJxNdzuB1iBjLOpIfxajM6YxdvwmQSLA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==", "cpu": [ "arm64" ], @@ -2310,9 +2298,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz", + "integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==", "cpu": [ "x64" ], @@ -2327,13 +2315,16 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz", + "integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2344,13 +2335,16 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz", + "integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2361,13 +2355,16 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz", + "integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2378,13 +2375,16 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz", + "integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2395,13 +2395,16 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz", + "integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2412,13 +2415,16 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz", + "integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2429,13 +2435,16 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz", + "integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2446,13 +2455,16 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz", + "integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2463,213 +2475,265 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.1.tgz", + "integrity": "sha512-jygmR02PpCYypt7xB7nst1vqjZp/BpRA/Kf9nK7qRponJ/KrLPaZWEG4G15z1d2FZ6XqI+T0350ha3RSnKx24A==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.0" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.1.tgz", + "integrity": "sha512-ErCRyGU7LeoaFBZ0xW8hhLlXzhAg80sc4vxePB86qvtEvW1jEhhmbiNBP4oEzZfPMnu6HwHXfzD2W2kBU+RnCw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.0" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.1.tgz", + "integrity": "sha512-LUWZ2+r2UoLCd8j0RLCwQ4gL6w47+Y7igxtVnPIDXOOEjV86LpBkAHq5VpJeg+GHbw0KN/JWlPJOdZjyZnFqFQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.0" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.1.tgz", + "integrity": "sha512-i7x6J3mwF4JgT0sM4V4WlAWdJ0bucPtA9rzO1bTji1n5qgBq/W5nn87RvOQPleuuxahNoLdTngByD8/vDDLArw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.0" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.1.tgz", + "integrity": "sha512-0zSaTUjTF0kIWTSYxD4EG/nvCU4jez53+3RdURtoY3HvbXtIQ98W90JnrGz/oLRFuEnfIy9+7xeq883euc0ZWw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.0" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.1.tgz", + "integrity": "sha512-NbJD4mWdeyrNQKluO/tR/wBDOelcowSVGNBWxI0e3ZtlXc6F/UOVKDj1MLD4zl3oHTuvKW3s+MA9N54YTldAYw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.0" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.1.tgz", + "integrity": "sha512-VoW2sQCWI+0YIKQEmWJ8vzaQjTg9wIyfkFpvEfAS2h43X6iHu7GTk1hhOgB4IpSzCHe8UwQZIcx7b81VTaOrJA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.0" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.1.tgz", + "integrity": "sha512-LjBoSd/c5JU0/K5MwzDMlgsSRP2bPn98JQGFFQAOLQ0bU/1z4ekxUdSKY9BmlwSh/cA+OrvpgsWqfZyYfVHBRw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.0" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.1.tgz", + "integrity": "sha512-PCQUoQdZyE8tp3HpbevuihfUmgSP4qWI0FGEPWoeXqaS+cUrFfemabHQiebUmUmlUhCuNnQMxGrQ+CPqK4hnxg==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.0" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.1.tgz", + "integrity": "sha512-xU2ml2bU2OPxYVvW2A6ae4M1g5QKyhKG06P4FAt+YEaFQQO0919Qx+XxIZEUuWTMoDViLpMws2/dQwoe/VcA6A==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.1.tgz", + "integrity": "sha512-IkmHwuFhYpd3bTsN5SAahjwhiAcyXPooBt8vEUgxY3T0IP70sSJ0nU1xiPzZY8AH/OB1XpV3j8aZSVSOSfTbdA==", "cpu": [ "arm64" ], @@ -2680,16 +2744,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.1.tgz", + "integrity": "sha512-wQahqCi9MD8Yxzg4gVM4fNrZxh+r6vD55PyIg+WJPaM5ZRUyF35iQpwJCuma3r6viU9/8Pxlc+XHV+woVa6nCQ==", "cpu": [ "ia32" ], @@ -2700,16 +2764,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.1.tgz", + "integrity": "sha512-WzBtkYtZHATLPe8XRharxZXxQ9cdLrQWHiwxt+BJ5rBsisQrKeeV86ErxPSVhcG6xCEuNhs0SqLpWr7XDa2k6w==", "cpu": [ "x64" ], @@ -2720,7 +2784,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -3456,6 +3520,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -3466,6 +3543,58 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -3474,27 +3603,27 @@ "license": "MIT" }, "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", "dev": true, "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", "dev": true, "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", - "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.10.tgz", + "integrity": "sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -3512,20 +3641,20 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.13.tgz", - "integrity": "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz", + "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==", "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collapsible": "1.1.13", - "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -3543,28 +3672,18 @@ } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collapsible": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.13.tgz", - "integrity": "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA==", + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.17.tgz", + "integrity": "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==", "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -3581,17 +3700,14 @@ } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", - "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -3608,80 +3724,103 @@ } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.10.tgz", + "integrity": "sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "dev": true, - "license": "MIT", "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.0.tgz", + "integrity": "sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.5.tgz", + "integrity": "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz", + "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==", "dev": true, "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { @@ -3699,14 +3838,17 @@ } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "node_modules/@radix-ui/react-collection": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -3723,16 +3865,12 @@ } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3743,2941 +3881,33 @@ } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.16.tgz", - "integrity": "sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dialog": "1.1.16", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", - "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", - "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.4.tgz", - "integrity": "sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-previous": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", - "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", - "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz", - "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.12", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.9", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.11", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5", - "@radix-ui/react-use-controllable-state": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", - "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", - "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", - "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.17.tgz", - "integrity": "sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.17", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-arrow": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", - "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-collection": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", - "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", - "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", - "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu": { - "version": "2.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.17.tgz", - "integrity": "sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.9", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.12", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.9", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.0", - "@radix-ui/react-portal": "1.1.11", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-roving-focus": "1.1.12", - "@radix-ui/react-slot": "1.2.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-popper": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", - "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.9", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-portal": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", - "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12.tgz", - "integrity": "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.9", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-form": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", - "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", - "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.9.tgz", - "integrity": "sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menubar": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", - "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", - "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", - "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", - "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.16.tgz", - "integrity": "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.12", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.9", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.0", - "@radix-ui/react-portal": "1.1.11", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5", - "@radix-ui/react-use-controllable-state": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-arrow": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", - "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", - "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", - "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", - "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.9", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", - "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.9.tgz", - "integrity": "sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-radio-group": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", - "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.11.tgz", - "integrity": "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", - "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.9.tgz", - "integrity": "sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", - "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", - "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-switch": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", - "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.14.tgz", - "integrity": "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==", + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.1.tgz", + "integrity": "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==", "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -6695,24 +3925,27 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", - "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", "dev": true, "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -6729,10 +3962,10 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6745,47 +3978,70 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz", + "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -6796,14 +4052,16 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -6820,14 +4078,19 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "node_modules/@radix-ui/react-form": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.10.tgz", + "integrity": "sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-label": "2.1.10", + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -6844,21 +4107,21 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12.tgz", - "integrity": "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==", + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.17.tgz", + "integrity": "sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==", "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -6876,12 +4139,15 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "node_modules/@radix-ui/react-id": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -6892,80 +4158,125 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "node_modules/@radix-ui/react-label": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.10.tgz", + "integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "node_modules/@radix-ui/react-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz", + "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.18.tgz", + "integrity": "sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", - "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz", + "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -6982,16 +4293,25 @@ } } }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", - "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.10.tgz", + "integrity": "sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7008,20 +4328,21 @@ } } }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", - "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.5.tgz", + "integrity": "sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1" }, "peerDependencies": { "@types/react": "*", @@ -7038,20 +4359,28 @@ } } }, - "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", - "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "node_modules/@radix-ui/react-popover": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", + "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-toggle-group": "1.1.11" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -7068,14 +4397,23 @@ } } }, - "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "node_modules/@radix-ui/react-popper": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7092,25 +4430,15 @@ } } }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.9.tgz", - "integrity": "sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==", + "node_modules/@radix-ui/react-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.12", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.0", - "@radix-ui/react-portal": "1.1.11", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-visually-hidden": "1.2.5" + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7127,21 +4455,14 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", - "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", + "node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7158,50 +4479,72 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "node_modules/@radix-ui/react-progress": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.10.tgz", + "integrity": "sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", - "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.1.tgz", + "integrity": "sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==", "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7218,42 +4561,54 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", - "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz", + "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==", "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7270,15 +4625,35 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", - "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", + "node_modules/@radix-ui/react-select": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz", + "integrity": "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -7295,14 +4670,14 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "node_modules/@radix-ui/react-separator": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.10.tgz", + "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -7319,14 +4694,24 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "node_modules/@radix-ui/react-slider": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.1.tgz", + "integrity": "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7343,12 +4728,15 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -7359,126 +4747,207 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "node_modules/@radix-ui/react-switch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.1.tgz", + "integrity": "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", + "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "node_modules/@radix-ui/react-toast": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.17.tgz", + "integrity": "sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.12.tgz", + "integrity": "sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==", "dev": true, "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.13.tgz", + "integrity": "sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-toggle": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.13.tgz", + "integrity": "sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-separator": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.13" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.5.tgz", - "integrity": "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==", + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.10.tgz", + "integrity": "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -7495,17 +4964,10 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", - "dev": true, - "license": "MIT" - }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7519,14 +4981,14 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7539,13 +5001,13 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7558,13 +5020,13 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7577,14 +5039,11 @@ } }, "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", - "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", "dev": true, "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.5.0" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -7596,9 +5055,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7612,9 +5071,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7628,13 +5087,13 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7647,13 +5106,13 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -7666,13 +5125,13 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -7690,9 +5149,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "dev": true, "license": "MIT" }, @@ -8934,18 +6393,6 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" - } - }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -9062,14 +6509,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -9080,29 +6519,18 @@ "@types/node": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", - "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/type-utils": "8.60.0", - "@typescript-eslint/utils": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -9115,7 +6543,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.0", + "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -9131,16 +6559,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", - "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "engines": { @@ -9156,14 +6584,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", - "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.0", - "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "engines": { @@ -9178,14 +6606,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", - "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9196,9 +6624,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", - "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, "license": "MIT", "engines": { @@ -9213,15 +6641,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", - "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -9238,9 +6666,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", - "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, "license": "MIT", "engines": { @@ -9252,16 +6680,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", - "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.0", - "@typescript-eslint/tsconfig-utils": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -9280,16 +6708,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", - "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9304,13 +6732,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", - "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -9710,13 +7138,6 @@ "addons/*" ] }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -9811,16 +7232,6 @@ } } }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -9865,40 +7276,36 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true, - "license": "MIT" - }, "node_modules/app-builder-lib": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", - "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", "dev": true, "license": "MIT", "dependencies": { - "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "^4.0.3", + "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.8.1", + "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -9906,7 +7313,8 @@ "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", - "minimatch": "^10.0.3", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", @@ -9914,14 +7322,15 @@ "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", "which": "^5.0.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.8.1", - "electron-builder-squirrel-windows": "26.8.1" + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" } }, "node_modules/app-builder-lib/node_modules/@electron/notarize": { @@ -10074,6 +7483,21 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -10122,17 +7546,6 @@ "dev": true, "license": "MIT" }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -10166,6 +7579,13 @@ "node": ">= 4.0.0" } }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/axios": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", @@ -10432,6 +7852,13 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -10503,42 +7930,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -10561,16 +7952,14 @@ } }, "node_modules/builder-util": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", - "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", "dev": true, "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.5.1", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", @@ -10583,12 +7972,15 @@ "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/builder-util-runtime": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", - "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "dev": true, "license": "MIT", "dependencies": { @@ -10649,6 +8041,16 @@ "node": ">= 0.8" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -10868,24 +8270,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -11352,17 +8736,6 @@ "node": ">=10.0.0" } }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -11414,9 +8787,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.4", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", - "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "dev": true, "license": "MIT", "engines": { @@ -11622,88 +8995,18 @@ } }, "node_modules/dmg-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", - "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-builder/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dmg-license/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/dmg-license/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -11915,6 +9218,16 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -11947,15 +9260,15 @@ } }, "node_modules/electron": { - "version": "42.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.2.0.tgz", - "integrity": "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw==", + "version": "42.4.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.4.1.tgz", + "integrity": "sha512-8CYHJP5O4wFO+ycoJR98yy907MmPeo+vWXrzjxmGGgRNKqv8pOjjm+wphO0CCgQJnBU7+QUPSJS4QXhbKrO50w==", "dev": true, "license": "MIT", "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", @@ -11966,18 +9279,18 @@ } }, "node_modules/electron-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", - "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.8.1", + "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -12022,15 +9335,16 @@ } }, "node_modules/electron-publish": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", - "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", @@ -12688,27 +10002,6 @@ "dev": true, "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -12776,16 +10069,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -13175,22 +10458,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/git-raw-commits": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", @@ -13653,24 +10920,6 @@ "@babel/runtime": "^7.23.2" } }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -13687,28 +10936,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -14997,9 +12224,9 @@ } }, "node_modules/lucide-react": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", - "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.20.0.tgz", + "integrity": "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ==", "dev": true, "license": "ISC", "peerDependencies": { @@ -16359,14 +13586,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -16438,6 +13657,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -16800,13 +14026,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16827,6 +14046,37 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -17157,6 +14407,26 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -17211,581 +14481,194 @@ "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/qrcode/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/radix-ui": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", - "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-accessible-icon": "1.1.7", - "@radix-ui/react-accordion": "1.2.12", - "@radix-ui/react-alert-dialog": "1.1.15", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-aspect-ratio": "1.1.7", - "@radix-ui/react-avatar": "1.1.10", - "@radix-ui/react-checkbox": "1.3.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-context-menu": "2.2.16", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-form": "0.1.8", - "@radix-ui/react-hover-card": "1.1.15", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-menubar": "1.1.16", - "@radix-ui/react-navigation-menu": "1.2.14", - "@radix-ui/react-one-time-password-field": "0.1.8", - "@radix-ui/react-password-toggle-field": "0.1.3", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-progress": "1.1.7", - "@radix-ui/react-radio-group": "1.3.8", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-scroll-area": "1.2.10", - "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-slider": "1.3.6", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-switch": "1.2.6", - "@radix-ui/react-tabs": "1.1.13", - "@radix-ui/react-toast": "1.2.15", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-toggle-group": "1.1.11", - "@radix-ui/react-toolbar": "1.1.11", - "@radix-ui/react-tooltip": "1.2.8", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-escape-keydown": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/radix-ui/node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/radix-ui/node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", - "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/radix-ui/node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/radix-ui/node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + }, + "engines": { + "node": ">=8" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", - "dev": true, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "p-locate": "^4.1.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", - "dev": true, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "p-try": "^2.0.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=6" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-progress": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", - "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", - "dev": true, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "ansi-regex": "^5.0.1" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", - "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", - "dev": true, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", - "dev": true, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dev": true, - "license": "MIT", + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=6" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "dev": true, - "license": "MIT", + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" + "side-channel": "^1.1.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=0.6" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "node_modules/radix-ui": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.0.tgz", + "integrity": "sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-accessible-icon": "1.1.10", + "@radix-ui/react-accordion": "1.2.14", + "@radix-ui/react-alert-dialog": "1.1.17", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-aspect-ratio": "1.1.10", + "@radix-ui/react-avatar": "1.2.0", + "@radix-ui/react-checkbox": "1.3.5", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-context-menu": "2.3.1", + "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dropdown-menu": "2.1.18", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-form": "0.1.10", + "@radix-ui/react-hover-card": "1.1.17", + "@radix-ui/react-label": "2.1.10", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-menubar": "1.1.18", + "@radix-ui/react-navigation-menu": "1.2.16", + "@radix-ui/react-one-time-password-field": "0.1.10", + "@radix-ui/react-password-toggle-field": "0.1.5", + "@radix-ui/react-popover": "1.1.17", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-progress": "1.1.10", + "@radix-ui/react-radio-group": "1.4.1", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-scroll-area": "1.2.12", + "@radix-ui/react-select": "2.3.1", + "@radix-ui/react-separator": "1.1.10", + "@radix-ui/react-slider": "1.4.1", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-switch": "1.3.1", + "@radix-ui/react-tabs": "1.1.15", + "@radix-ui/react-toast": "1.2.17", + "@radix-ui/react-toggle": "1.1.12", + "@radix-ui/react-toggle-group": "1.1.13", + "@radix-ui/react-toolbar": "1.1.13", + "@radix-ui/react-tooltip": "1.2.10", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-escape-keydown": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -17912,9 +14795,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.76.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz", - "integrity": "sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==", + "version": "7.79.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", + "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", "dev": true, "license": "MIT", "engines": { @@ -18494,9 +15377,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -18569,48 +15452,48 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.1.tgz", + "integrity": "sha512-lW979AMi+ESidzMv/Lnv+F9bknzLyxLqFI05Sm433vOeRcltgxQmXpnfOOFIAlKtwXU/ksupm2srQoFCkR214g==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.1", + "@img/sharp-darwin-x64": "0.35.1", + "@img/sharp-freebsd-wasm32": "0.35.1", + "@img/sharp-libvips-darwin-arm64": "1.3.0", + "@img/sharp-libvips-darwin-x64": "1.3.0", + "@img/sharp-libvips-linux-arm": "1.3.0", + "@img/sharp-libvips-linux-arm64": "1.3.0", + "@img/sharp-libvips-linux-ppc64": "1.3.0", + "@img/sharp-libvips-linux-riscv64": "1.3.0", + "@img/sharp-libvips-linux-s390x": "1.3.0", + "@img/sharp-libvips-linux-x64": "1.3.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.0", + "@img/sharp-libvips-linuxmusl-x64": "1.3.0", + "@img/sharp-linux-arm": "0.35.1", + "@img/sharp-linux-arm64": "0.35.1", + "@img/sharp-linux-ppc64": "0.35.1", + "@img/sharp-linux-riscv64": "0.35.1", + "@img/sharp-linux-s390x": "0.35.1", + "@img/sharp-linux-x64": "0.35.1", + "@img/sharp-linuxmusl-arm64": "0.35.1", + "@img/sharp-linuxmusl-x64": "0.35.1", + "@img/sharp-webcontainers-wasm32": "0.35.1", + "@img/sharp-win32-arm64": "0.35.1", + "@img/sharp-win32-ia32": "0.35.1", + "@img/sharp-win32-x64": "0.35.1" } }, "node_modules/shebang-command": { @@ -18814,22 +15697,6 @@ "node": ">=18" } }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -19399,9 +16266,9 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -19617,16 +16484,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", - "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.0", - "@typescript-eslint/parser": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0" + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -19767,6 +16634,35 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -20190,6 +17086,20 @@ "loose-envify": "^1.0.0" } }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -20432,17 +17342,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 83d6cbed9..e5aa4d5ca 100644 --- a/package.json +++ b/package.json @@ -91,11 +91,11 @@ "@radix-ui/react-popover": "^1.1.16", "@radix-ui/react-progress": "^1.1.9", "@radix-ui/react-scroll-area": "^1.2.11", - "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-select": "^2.3.1", "@radix-ui/react-separator": "^1.1.9", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-slider": "^1.4.1", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-switch": "^1.3.1", "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", "@tailwindcss/vite": "^4.2.4", @@ -133,9 +133,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "concurrently": "^9.2.1", - "cytoscape": "^3.33.2", - "electron": "^42.2.0", - "electron-builder": "^26.8.1", + "cytoscape": "^3.34.0", + "electron": "^42.4.1", + "electron-builder": "^26.15.3", "eslint": "^9.0.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -147,14 +147,14 @@ "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.1.1", "lint-staged": "^17.0.7", - "lucide-react": "^1.11.0", + "lucide-react": "^1.20.0", "prettier": "3.8.3", - "radix-ui": "^1.4.3", + "radix-ui": "^1.6.0", "react": "^19.2.7", "react-cytoscapejs": "^2.0.0", "react-dom": "^19.2.7", "react-h5-audio-player": "^3.10.2", - "react-hook-form": "^7.73.1", + "react-hook-form": "^7.79.0", "react-i18next": "^17.0.4", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", @@ -163,13 +163,13 @@ "react-syntax-highlighter": "^16.1.1", "react-xtermjs": "^1.0.10", "remark-gfm": "^4.0.1", - "sharp": "^0.34.5", + "sharp": "^0.35.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", "typescript": "~6.0.3", - "typescript-eslint": "^8.59.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-plugin-svgr": "^5.2.0", "vitest": "^4.1.8" From 6399160dc2f6989e917a198e852bdf92f5d275df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:42:34 -0500 Subject: [PATCH 02/17] chore(deps): bump node in /docker in the docker-major-updates group (#914) Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b9a8a1495..64c22583f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Install dependencies -FROM node:24-slim AS deps +FROM node:26-slim AS deps WORKDIR /app RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* @@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3 RUN npm run build:backend # Stage 4: Production dependencies only -FROM node:24-slim AS production-deps +FROM node:26-slim AS production-deps WORKDIR /app RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* @@ -53,7 +53,7 @@ RUN npm ci --omit=dev --ignore-scripts && \ npm cache clean --force # Stage 5: Final optimized image -FROM node:24-slim +FROM node:26-slim WORKDIR /app ENV DATA_DIR=/app/data \ From 2158008b3c0897a2f8e859c5e9efd3eed94e834e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:43:13 -0500 Subject: [PATCH 03/17] ci(deps): bump the github-actions group with 2 updates (#915) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependabot-retarget.yml | 2 +- .github/workflows/release.yml | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/dependabot-retarget.yml b/.github/workflows/dependabot-retarget.yml index 9a01d138f..36f904cad 100644 --- a/.github/workflows/dependabot-retarget.yml +++ b/.github/workflows/dependabot-retarget.yml @@ -14,7 +14,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0ec67499..d1901d24f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,14 +85,14 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout dev branch - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ needs.prep.outputs.dev_branch }} fetch-depth: 0 token: ${{ secrets.GHCR_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" cache: "npm" @@ -137,14 +137,14 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout dev branch - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ needs.prep.outputs.dev_branch }} fetch-depth: 0 token: ${{ secrets.GHCR_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" @@ -287,13 +287,13 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout main - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: main fetch-depth: 1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" @@ -397,14 +397,14 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout Termix - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ needs.merge-to-main.outputs.build_ref }} fetch-depth: 1 path: termix - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: "termix/.nvmrc" cache: "npm" @@ -417,7 +417,7 @@ jobs: npm run generate:openapi - name: Checkout Docs repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: Termix-SSH/Docs ref: main @@ -486,13 +486,13 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout main - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: main fetch-depth: 1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" From 508b78f40c6ff65a6df51f6f9050d14857b03d9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:43:52 -0500 Subject: [PATCH 04/17] chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916) Bumps the prod-minor-updates group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` | | [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` | | [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` | | [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` | Updates `axios` from 1.17.0 to 1.18.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0) Updates `better-sqlite3` from 12.10.0 to 12.11.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1) Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `multer` from 2.1.1 to 2.2.0 - [Release notes](https://github.com/expressjs/multer/releases) - [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md) - [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0) Updates `undici` from 8.4.0 to 8.5.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: better-sqlite3 dependency-version: 12.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: multer dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 65 ++++++++++++++++++++++++++++------------------- package.json | 10 ++++---- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41fe6b0bd..76cd40549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "hasInstallScript": true, "dependencies": { "@types/ldapjs": "^3.0.6", - "axios": "^1.17.0", + "axios": "^1.18.0", "bcryptjs": "^3.0.3", - "better-sqlite3": "^12.9.0", - "body-parser": "^2.2.2", + "better-sqlite3": "^12.11.1", + "body-parser": "^2.3.0", "chalk": "^5.6.2", "cookie-parser": "^1.4.7", "cors": "^2.8.6", @@ -27,13 +27,13 @@ "jszip": "^3.10.1", "ldapjs": "^3.0.7", "motion": "^12.38.0", - "multer": "^2.1.1", + "multer": "^2.2.0", "nanoid": "^5.1.9", "qrcode": "^1.5.4", "socks": "^2.8.7", "speakeasy": "^2.0.0", "ssh2": "^1.17.0", - "undici": "^8.4.0", + "undici": "^8.5.0", "ws": "^8.20.0" }, "devDependencies": { @@ -7587,9 +7587,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", - "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -7820,9 +7820,9 @@ } }, "node_modules/better-sqlite3": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", - "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -7860,20 +7860,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -7883,6 +7883,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -13454,9 +13467,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", - "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -16508,9 +16521,9 @@ } }, "node_modules/undici": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.4.0.tgz", - "integrity": "sha512-tDR4LgFBeV7YBWOFMlmbhtrnFVWuZ6HKbkG+88/csQdhK2tPlW78PnLI2VRjIQtTlslvgSZ4R43WBE+4g6rhng==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index e5aa4d5ca..f197cc042 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,10 @@ }, "dependencies": { "@types/ldapjs": "^3.0.6", - "axios": "^1.17.0", + "axios": "^1.18.0", "bcryptjs": "^3.0.3", - "better-sqlite3": "^12.9.0", - "body-parser": "^2.2.2", + "better-sqlite3": "^12.11.1", + "body-parser": "^2.3.0", "chalk": "^5.6.2", "cookie-parser": "^1.4.7", "cors": "^2.8.6", @@ -59,13 +59,13 @@ "jszip": "^3.10.1", "ldapjs": "^3.0.7", "motion": "^12.38.0", - "multer": "^2.1.1", + "multer": "^2.2.0", "nanoid": "^5.1.9", "qrcode": "^1.5.4", "socks": "^2.8.7", "speakeasy": "^2.0.0", "ssh2": "^1.17.0", - "undici": "^8.4.0", + "undici": "^8.5.0", "ws": "^8.20.0" }, "devDependencies": { From ff6433b0e98d4a5e62570012be5a1a03a8e440dc Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 16 Jun 2026 21:54:19 -0500 Subject: [PATCH 05/17] feat: add unlink button for dual auth --- src/ui/locales/en.json | 7 ++ src/ui/sidebar/AdminManagementSections.tsx | 26 +++++++- src/ui/sidebar/AdminSettingsPanel.tsx | 25 ++++++++ src/ui/sidebar/AdminUserDialogs.tsx | 75 ++++++++++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 54e5c5bdd..a7bf30889 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2193,6 +2193,13 @@ "linkAccountSuccess": "Accounts linked successfully", "linkAccountFailed": "Failed to link accounts", "linkAccountInProgress": "Linking...", + "unlinkAccountTitle": "Unlink OIDC", + "unlinkAccountDesc": "Remove OIDC authentication from {{username}}. They will only be able to log in with their password.", + "unlinkAccountWarning": "This will remove OIDC login from this account. The user must have a password set to continue logging in.", + "unlinkAccount": "Unlink OIDC", + "unlinkAccountInProgress": "Unlinking...", + "unlinkAccountSuccess": "OIDC unlinked successfully", + "unlinkAccountFailed": "Failed to unlink OIDC", "saving": "Saving...", "updateRegistrationFailed": "Failed to update registration setting", "updatePasswordLoginFailed": "Failed to update password login setting", diff --git a/src/ui/sidebar/AdminManagementSections.tsx b/src/ui/sidebar/AdminManagementSections.tsx index 3c862b856..e1b0e3d5b 100644 --- a/src/ui/sidebar/AdminManagementSections.tsx +++ b/src/ui/sidebar/AdminManagementSections.tsx @@ -17,6 +17,7 @@ import { RefreshCw, Share2, Trash2, + Unlink, User, } from "lucide-react"; import { toast } from "sonner"; @@ -68,6 +69,10 @@ type UsersSectionProps = { SetStateAction<{ id: string; username: string; isOidc: boolean } | null> >; setLinkAccountOpen: Dispatch>; + setUnlinkAccountTarget: Dispatch< + SetStateAction<{ id: string; username: string } | null> + >; + setUnlinkAccountOpen: Dispatch>; }; export function AdminUsersSection({ @@ -81,6 +86,8 @@ export function AdminUsersSection({ setEditUserOpen, setLinkAccountTarget, setLinkAccountOpen, + setUnlinkAccountTarget, + setUnlinkAccountOpen, }: UsersSectionProps) { const { t } = useTranslation(); @@ -160,11 +167,28 @@ export function AdminUsersSection({ > - {!(user.isOidc && user.passwordHash) && ( + {user.isOidc && user.passwordHash ? ( + + ) : ( + + + + + ); +} + type LinkAccountDialogProps = { open: boolean; onOpenChange: Dispatch>; From f4265775e567af9ba768ac9f02db8df50f6b9278 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 18 Jun 2026 02:09:20 -0500 Subject: [PATCH 06/17] fix: general bug fixes --- .gitignore | 2 + docker/nginx-https.conf | 84 ++++--- docker/nginx.conf | 86 ++++--- electron/main.cjs | 5 + src/backend/dashboard.ts | 30 ++- src/backend/database/database.ts | 6 +- src/backend/database/routes/credentials.ts | 10 +- .../database/routes/host-bulk-routes.ts | 6 + .../database/routes/host-internal-routes.ts | 4 +- .../database/routes/host-normalizers.ts | 2 +- src/backend/database/routes/host.ts | 25 +- .../database/routes/ldap-auth-routes.ts | 35 ++- src/backend/database/routes/open-tabs.ts | 21 +- src/backend/database/routes/rbac.ts | 16 +- .../database/routes/sso-provider-routes.ts | 11 + .../database/routes/user-admin-routes.ts | 96 ++++++- .../database/routes/user-preferences.ts | 2 +- .../database/routes/user-totp-routes.ts | 119 +++++++-- src/backend/database/routes/users.ts | 47 +++- src/backend/guacamole/routes.ts | 16 +- src/backend/ssh/auth-manager.ts | 26 +- src/backend/ssh/docker-container-routes.ts | 15 +- src/backend/ssh/docker.ts | 31 ++- .../ssh/file-manager-operation-routes.ts | 25 +- src/backend/ssh/file-manager.ts | 106 ++++++-- src/backend/ssh/host-key-verifier.ts | 61 ++++- src/backend/ssh/host-metrics.ts | 6 +- src/backend/ssh/terminal.ts | 112 ++++----- src/types/index.ts | 18 +- src/types/ui-types.ts | 10 +- src/ui/AppShell.tsx | 2 +- src/ui/api/ssh-file-operations-api.ts | 9 +- src/ui/auth/Auth.tsx | 23 ++ src/ui/auth/ElectronLoginForm.tsx | 32 ++- src/ui/auth/LoginPage.tsx | 234 ++++++++++-------- src/ui/dashboard/DashboardTab.tsx | 2 +- .../features/docker/components/LogViewer.tsx | 2 +- src/ui/features/file-manager/FileManager.tsx | 102 ++++++++ .../file-manager/FileManagerSidebar.tsx | 16 +- .../file-manager/components/FileViewer.tsx | 14 +- .../file-manager/components/FileWindow.tsx | 4 + .../terminal/MobileTerminalKeyboard.tsx | 179 ++++++++++++++ src/ui/features/terminal/Terminal.tsx | 33 ++- src/ui/features/terminal/terminal-types.ts | 1 + src/ui/lib/clipboard-provider.ts | 14 +- src/ui/locales/en.json | 17 ++ src/ui/shell/tabUtils.tsx | 48 ++-- src/ui/sidebar/AdminSettingsPanel.tsx | 5 +- src/ui/sidebar/CredentialEditorView.tsx | 11 +- src/ui/sidebar/HostEditor.tsx | 15 +- src/ui/sidebar/HostEditorGeneralTab.tsx | 14 +- src/ui/sidebar/HostEditorGuacamoleTabs.tsx | 22 ++ src/ui/sidebar/HostManagerData.ts | 2 +- src/ui/sidebar/SessionLogsPanel.tsx | 9 +- src/ui/sidebar/SidebarTree.tsx | 13 +- src/ui/sidebar/SshToolsPanel.tsx | 31 ++- src/ui/ssh/dialogs/SSHAuthDialog.tsx | 2 +- 57 files changed, 1495 insertions(+), 394 deletions(-) create mode 100644 src/ui/features/terminal/MobileTerminalKeyboard.tsx diff --git a/.gitignore b/.gitignore index b891f5f80..d9251b2bc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ electron/build-info.cjs /.mcp.json /CLAUDE.md /old_db/ +/scripts/fix-bugs.mjs +/scripts/fix-features.mjs diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index a3036727d..b98ae8e58 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -24,7 +24,11 @@ http { client_header_timeout 300s; set_real_ip_from 127.0.0.1; + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 192.168.0.0/16; real_ip_header X-Forwarded-For; + real_ip_recursive on; map $http_x_forwarded_proto $proxy_x_forwarded_proto { default $http_x_forwarded_proto; @@ -128,7 +132,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/releases(/.*)?$ { @@ -137,7 +141,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/alerts(/.*)?$ { @@ -146,7 +150,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/rbac(/.*)?$ { @@ -155,7 +159,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/credentials(/.*)?$ { @@ -164,7 +168,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -177,7 +181,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/c2s-tunnel-presets(/.*)?$ { @@ -186,7 +190,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/audit-logs(/.*)?$ { @@ -195,7 +199,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/terminal(/.*)?$ { @@ -204,7 +208,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/open-tabs(/.*)?$ { @@ -213,7 +217,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/user-preferences(/.*)?$ { @@ -222,7 +226,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/database(/.*)?$ { @@ -234,7 +238,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -253,7 +257,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -269,7 +273,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/quick-connect { @@ -281,7 +285,7 @@ http { proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/host/opkssh-chooser(/.*)?$ { @@ -320,7 +324,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /session_logs/ { @@ -329,7 +333,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /tailscale/ { @@ -338,7 +342,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /ssh/websocket/ { @@ -377,7 +381,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $http_host; @@ -397,7 +401,7 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/tunnel/ { @@ -406,7 +410,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /ssh/tunnel/ { @@ -417,7 +421,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_read_timeout 86400s; proxy_send_timeout 86400s; proxy_buffering off; @@ -430,7 +434,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/file_manager/pinned { @@ -439,7 +443,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/file_manager/shortcuts { @@ -448,7 +452,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/file_manager/sudo-password { @@ -457,7 +461,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /ssh/file_manager/ { @@ -471,7 +475,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -492,7 +496,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -508,7 +512,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /health { @@ -517,7 +521,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/status(/.*)?$ { @@ -526,7 +530,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/metrics(/.*)?$ { @@ -535,7 +539,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 60s; @@ -548,7 +552,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/host-metrics(/.*)?$ { @@ -557,7 +561,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 600s; proxy_send_timeout 600s; @@ -570,7 +574,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/uptime(/.*)?$ { @@ -579,7 +583,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/activity(/.*)?$ { @@ -588,7 +592,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ^~ /docker/console/ { @@ -602,7 +606,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $http_host; @@ -623,7 +627,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -637,7 +641,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; diff --git a/docker/nginx.conf b/docker/nginx.conf index dd5eabda2..79fd92d0f 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -24,7 +24,11 @@ http { client_header_timeout 300s; set_real_ip_from 127.0.0.1; + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 192.168.0.0/16; real_ip_header X-Forwarded-For; + real_ip_recursive on; map $http_x_forwarded_proto $proxy_x_forwarded_proto { default $http_x_forwarded_proto; @@ -117,7 +121,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/releases(/.*)?$ { @@ -126,7 +130,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/alerts(/.*)?$ { @@ -135,7 +139,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/rbac(/.*)?$ { @@ -144,7 +148,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/credentials(/.*)?$ { @@ -153,7 +157,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -166,7 +170,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/proxmox(/.*)?$ { @@ -175,7 +179,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 120s; proxy_read_timeout 120s; @@ -187,7 +191,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/audit-logs(/.*)?$ { @@ -196,7 +200,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/terminal(/.*)?$ { @@ -205,7 +209,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/open-tabs(/.*)?$ { @@ -214,7 +218,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/user-preferences(/.*)?$ { @@ -223,7 +227,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/database(/.*)?$ { @@ -235,7 +239,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -254,7 +258,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -270,7 +274,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/quick-connect { @@ -282,7 +286,7 @@ http { proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/host/opkssh-chooser(/.*)?$ { @@ -321,7 +325,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /session_logs/ { @@ -330,7 +334,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /tailscale/ { @@ -339,7 +343,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /ssh/websocket/ { @@ -378,7 +382,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $http_host; @@ -398,7 +402,7 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/tunnel/ { @@ -407,7 +411,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /ssh/tunnel/ { @@ -418,7 +422,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_read_timeout 86400s; proxy_send_timeout 86400s; proxy_buffering off; @@ -431,7 +435,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/file_manager/pinned { @@ -440,7 +444,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/file_manager/shortcuts { @@ -449,7 +453,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /host/file_manager/sudo-password { @@ -458,7 +462,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /ssh/file_manager/ { @@ -472,7 +476,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -493,7 +497,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -509,7 +513,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location /health { @@ -518,7 +522,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/status(/.*)?$ { @@ -527,7 +531,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/metrics(/.*)?$ { @@ -536,7 +540,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 60s; @@ -549,7 +553,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/host-metrics(/.*)?$ { @@ -558,7 +562,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 600s; proxy_send_timeout 600s; @@ -571,7 +575,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/uptime(/.*)?$ { @@ -580,7 +584,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~ ^/activity(/.*)?$ { @@ -589,7 +593,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ^~ /docker/console/ { @@ -603,7 +607,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $http_host; @@ -624,7 +628,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; @@ -638,7 +642,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_connect_timeout 60s; proxy_send_timeout 300s; diff --git a/electron/main.cjs b/electron/main.cjs index 104ec5a26..5534553e2 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -476,6 +476,11 @@ if (process.platform === "linux") { app.commandLine.appendSwitch("--ozone-platform-hint=auto"); app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder"); + + // Fix click-coordinate misalignment with fractional display scaling on KDE/Wayland. + // Chromium's hit-testing uses unscaled coords while the compositor scales visually, + // so forcing scale factor 1 keeps them in sync. See: https://github.com/brave/brave-browser/issues/50028 + app.commandLine.appendSwitch("--force-device-scale-factor", "1"); } if (process.platform === "win32") { diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index 98814fa2c..b76fab509 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -2,8 +2,13 @@ import express from "express"; import cookieParser from "cookie-parser"; import { createCorsMiddleware } from "./utils/cors-config.js"; import { getDb } from "./database/db/index.js"; -import { recentActivity, hosts, hostAccess } from "./database/db/schema.js"; -import { eq, and, desc, inArray } from "drizzle-orm"; +import { + recentActivity, + hosts, + hostAccess, + userRoles, +} from "./database/db/schema.js"; +import { eq, and, desc, inArray, or, isNull, gte, sql } from "drizzle-orm"; import { dashboardLogger } from "./utils/logger.js"; import { SimpleDBOps } from "./utils/simple-db-ops.js"; import { AuthManager } from "./utils/auth-manager.js"; @@ -227,11 +232,30 @@ app.post("/activity/log", async (req, res) => { ); if (ownedHosts.length === 0) { + const userRoleIds = await getDb() + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, userId)); + const roleIds = userRoleIds.map((r) => r.roleId); + + const now = new Date().toISOString(); const sharedHosts = await getDb() .select() .from(hostAccess) .where( - and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), + and( + eq(hostAccess.hostId, hostId), + or( + eq(hostAccess.userId, userId), + roleIds.length > 0 + ? sql`${hostAccess.roleId} IN (${sql.join( + roleIds.map((id) => sql`${id}`), + sql`, `, + )})` + : sql`false`, + ), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), ); if (sharedHosts.length === 0) { diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index b8a0fd1f8..2d93745cc 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1832,7 +1832,11 @@ if (frontendDist) { ); app.use((req, res, next) => { - if (req.method === "GET" && req.accepts("html")) { + if ( + req.method === "GET" && + req.accepts("html") && + !req.headers.authorization + ) { res.setHeader( "Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0", diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 79f6e6a8b..d0b554787 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -154,7 +154,9 @@ router.post( error: keyInfo.error, }); return res.status(400).json({ - error: `Invalid SSH key: ${keyInfo.error}`, + error: keyInfo.error + ? `Invalid SSH key: ${keyInfo.error}` + : "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).", }); } } @@ -525,7 +527,9 @@ router.put( error: keyInfo.error, }); return res.status(400).json({ - error: `Invalid SSH key: ${keyInfo.error}`, + error: keyInfo.error + ? `Invalid SSH key: ${keyInfo.error}` + : "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).", }); } updateFields.privateKey = keyInfo.privateKey; @@ -986,7 +990,7 @@ function formatSSHHostOutput( tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections as string) : [], - enableFileManager: !!host.enableFileManager, + enableFileManager: host.enableFileManager !== false, defaultPath: host.defaultPath, createdAt: host.createdAt, updatedAt: host.updatedAt, diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 935c07274..3f72fb516 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -363,6 +363,12 @@ export function registerHostBulkRoutes( if (fallback.length > 0) { hostData.credentialId = fallback[0].id; + } else if (isNonEmptyString(hostData.key)) { + hostData.authType = "key"; + hostData.credentialId = undefined; + } else if (isNonEmptyString(hostData.password)) { + hostData.authType = "password"; + hostData.credentialId = undefined; } else { results.failed++; results.errors.push( diff --git a/src/backend/database/routes/host-internal-routes.ts b/src/backend/database/routes/host-internal-routes.ts index 3bafed6a7..32988f61c 100644 --- a/src/backend/database/routes/host-internal-routes.ts +++ b/src/backend/database/routes/host-internal-routes.ts @@ -82,7 +82,7 @@ export function registerHostInternalRoutes(router: Router): void { ), pin: !!host.pin, enableTerminal: !!host.enableTerminal, - enableFileManager: !!host.enableFileManager, + enableFileManager: host.enableFileManager !== false, showTerminalInSidebar: !!host.showTerminalInSidebar, showFileManagerInSidebar: !!host.showFileManagerInSidebar, showTunnelInSidebar: !!host.showTunnelInSidebar, @@ -155,7 +155,7 @@ export function registerHostInternalRoutes(router: Router): void { tunnelConnections: tunnelConnections, pin: !!host.pin, enableTerminal: !!host.enableTerminal, - enableFileManager: !!host.enableFileManager, + enableFileManager: host.enableFileManager !== false, showTerminalInSidebar: !!host.showTerminalInSidebar, showFileManagerInSidebar: !!host.showFileManagerInSidebar, showTunnelInSidebar: !!host.showTunnelInSidebar, diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index edb7576b8..621164b26 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -237,7 +237,7 @@ export function transformHostResponse( pin: !!host.pin, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, - enableFileManager: !!host.enableFileManager, + enableFileManager: host.enableFileManager !== false, enableDocker: !!host.enableDocker, enableProxmox: !!host.enableProxmox, enableTmuxMonitor: !!host.enableTmuxMonitor, diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index a76d0dddf..6e0d18230 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -952,10 +952,9 @@ router.put( sshDataObj.keyType = null; } - if (rdpPassword !== undefined) sshDataObj.rdpPassword = rdpPassword || null; - if (vncPassword !== undefined) sshDataObj.vncPassword = vncPassword || null; - if (telnetPassword !== undefined) - sshDataObj.telnetPassword = telnetPassword || null; + if (rdpPassword) sshDataObj.rdpPassword = rdpPassword; + if (vncPassword) sshDataObj.vncPassword = vncPassword; + if (telnetPassword) sshDataObj.telnetPassword = telnetPassword; try { const accessInfo = await permissionManager.canAccessHost( @@ -1445,7 +1444,19 @@ router.get( const host = data[0]; const resolved = (await resolveHostCredentials(host, userId)) || host; - const value = resolved[field]; + let value = resolved[field]; + + if (!value && field === "sudoPassword" && resolved.terminalConfig) { + try { + const tc = + typeof resolved.terminalConfig === "string" + ? JSON.parse(resolved.terminalConfig) + : resolved.terminalConfig; + value = tc?.sudoPassword || null; + } catch { + // malformed JSON — leave value null + } + } if (!value) { return res.status(404).json({ error: "No password set" }); @@ -1574,7 +1585,7 @@ router.get( !!resolvedHost.overrideCredentialUsername, enableTerminal: !!resolvedHost.enableTerminal, enableTunnel: !!resolvedHost.enableTunnel, - enableFileManager: !!resolvedHost.enableFileManager, + enableFileManager: resolvedHost.enableFileManager !== false, enableDocker: !!resolvedHost.enableDocker, enableProxmox: !!resolvedHost.enableProxmox, enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor, @@ -1722,7 +1733,7 @@ router.get( !!resolvedHost.overrideCredentialUsername, enableTerminal: !!resolvedHost.enableTerminal, enableTunnel: !!resolvedHost.enableTunnel, - enableFileManager: !!resolvedHost.enableFileManager, + enableFileManager: resolvedHost.enableFileManager !== false, enableDocker: !!resolvedHost.enableDocker, enableProxmox: !!resolvedHost.enableProxmox, enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor, diff --git a/src/backend/database/routes/ldap-auth-routes.ts b/src/backend/database/routes/ldap-auth-routes.ts index c3fcf63de..c4d347060 100644 --- a/src/backend/database/routes/ldap-auth-routes.ts +++ b/src/backend/database/routes/ldap-auth-routes.ts @@ -2,7 +2,7 @@ import type { Router } from "express"; import type { LDAPProviderConfig } from "../../../types/index.js"; import { db } from "../db/index.js"; import { ssoProviders, users, roles, userRoles } from "../db/schema.js"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { nanoid } from "nanoid"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -375,6 +375,39 @@ export function registerLDAPAuthRoutes(router: Router): void { if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) { await db.update(users).set({ isAdmin }).where(eq(users.id, userId)); existingUsers[0].isAdmin = isAdmin; + try { + const newRoleName = isAdmin ? "admin" : "user"; + const oldRoleName = isAdmin ? "user" : "admin"; + const newRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, newRoleName)) + .limit(1); + const oldRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, oldRoleName)) + .limit(1); + if (oldRole.length > 0) { + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, userId), + eq(userRoles.roleId, oldRole[0].id), + ), + ); + } + if (newRole.length > 0) { + await db.insert(userRoles).values({ + userId, + roleId: newRole[0].id, + grantedBy: userId, + }); + } + } catch { + /* non-fatal */ + } } // Update display name if not dual-auth diff --git a/src/backend/database/routes/open-tabs.ts b/src/backend/database/routes/open-tabs.ts index 0478115c1..b5e04ad3d 100644 --- a/src/backend/database/routes/open-tabs.ts +++ b/src/backend/database/routes/open-tabs.ts @@ -23,7 +23,24 @@ const authenticateJWT = authManager.createAuthMiddleware(); * 200: * description: List of open tabs ordered by tab_order. */ -const TAB_TTL_MS = 30 * 60 * 1000; +const DEFAULT_TAB_TTL_MINUTES = 30; + +function getTabTtlMs(): number { + try { + const row = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", + ) + .get() as { value: string } | undefined; + if (row) { + const minutes = parseInt(row.value, 10); + if (!isNaN(minutes) && minutes > 0) return minutes * 60_000; + } + } catch { + // DB not available, use default + } + return DEFAULT_TAB_TTL_MINUTES * 60_000; +} // Legacy tab types that were renamed. Normalize on read so previously saved // tabs still restore to the correct (renamed) tab type. @@ -38,7 +55,7 @@ function normalizeTabType(tabType: string): string { router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const cutoff = new Date(Date.now() - TAB_TTL_MS).toISOString(); + const cutoff = new Date(Date.now() - getTabTtlMs()).toISOString(); const tabs = db .select() .from(userOpenTabs) diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index c7a7ca8bc..97227da1d 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -487,6 +487,12 @@ router.get( try { const now = new Date().toISOString(); + const userRoleIds = await db + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, userId)); + const roleIds = userRoleIds.map((r) => r.roleId); + const sharedHosts = await db .select({ id: hosts.id, @@ -506,7 +512,15 @@ router.get( .innerJoin(users, eq(hosts.userId, users.id)) .where( and( - eq(hostAccess.userId, userId), + or( + eq(hostAccess.userId, userId), + roleIds.length > 0 + ? sql`${hostAccess.roleId} IN (${sql.join( + roleIds.map((id) => sql`${id}`), + sql`, `, + )})` + : sql`false`, + ), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ) diff --git a/src/backend/database/routes/sso-provider-routes.ts b/src/backend/database/routes/sso-provider-routes.ts index 662f48f40..7a2d068f9 100644 --- a/src/backend/database/routes/sso-provider-routes.ts +++ b/src/backend/database/routes/sso-provider-routes.ts @@ -9,6 +9,7 @@ import { eq, asc } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { SSOProviderType } from "../../../types/index.js"; +import { getOIDCConfigFromEnv } from "./user-oidc-utils.js"; const authManager = AuthManager.getInstance(); @@ -117,6 +118,16 @@ export function registerSSOProviderRoutes(router: Router): void { .from(ssoProviders) .where(eq(ssoProviders.enabled, true)) .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + + // If no DB providers exist, synthesize one from env vars so SSO login + // remains available when configured purely via environment variables. + if (providers.length === 0) { + const envConfig = getOIDCConfigFromEnv(); + if (envConfig) { + providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }); + } + } + res.json(providers); } catch (err) { authLogger.error("Failed to list SSO providers", err); diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index fe53cc6aa..d5fded975 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -1,9 +1,9 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; import { db } from "../db/index.js"; -import { users } from "../db/schema.js"; +import { users, roles, userRoles } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; function isNonEmptyString(val: unknown): val is string { @@ -139,6 +139,50 @@ export function registerUserAdminRoutes( : eq(users.username, resolvedUsername!), ); + try { + const targetId = targetUser[0].id; + const adminRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, "admin")) + .limit(1); + const userRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, "user")) + .limit(1); + if (adminRole.length > 0) { + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, targetId), + eq(userRoles.roleId, adminRole[0].id), + ), + ); + await db.insert(userRoles).values({ + userId: targetId, + roleId: adminRole[0].id, + grantedBy: userId, + }); + } + if (userRole.length > 0) { + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, targetId), + eq(userRoles.roleId, userRole[0].id), + ), + ); + } + } catch (roleError) { + authLogger.error("Failed to sync admin role on make-admin", roleError, { + operation: "make_admin_role_sync", + userId: targetUser[0].id, + }); + } + try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); @@ -277,6 +321,54 @@ export function registerUserAdminRoutes( : eq(users.username, resolvedUsername!), ); + try { + const targetId = targetUser[0].id; + const adminRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, "admin")) + .limit(1); + const userRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, "user")) + .limit(1); + if (adminRole.length > 0) { + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, targetId), + eq(userRoles.roleId, adminRole[0].id), + ), + ); + } + if (userRole.length > 0) { + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, targetId), + eq(userRoles.roleId, userRole[0].id), + ), + ); + await db.insert(userRoles).values({ + userId: targetId, + roleId: userRole[0].id, + grantedBy: userId, + }); + } + } catch (roleError) { + authLogger.error( + "Failed to sync user role on remove-admin", + roleError, + { + operation: "remove_admin_role_sync", + userId: targetUser[0].id, + }, + ); + } + try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index 97df7643a..74bb23924 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -17,7 +17,7 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ fontSize: row?.fontSize ?? null, accentColor: row?.accentColor ?? null, language: row?.language ?? null, - storageMode: row?.storageMode ?? "local", + storageMode: row?.storageMode ?? "cloud", commandAutocomplete: row?.commandAutocomplete ?? null, commandPaletteEnabled: row?.commandPaletteEnabled ?? null, showHostTags: row?.showHostTags ?? null, diff --git a/src/backend/database/routes/user-totp-routes.ts b/src/backend/database/routes/user-totp-routes.ts index 32813359f..07b78efd9 100644 --- a/src/backend/database/routes/user-totp-routes.ts +++ b/src/backend/database/routes/user-totp-routes.ts @@ -5,6 +5,7 @@ import bcrypt from "bcryptjs"; import QRCode from "qrcode"; import speakeasy from "speakeasy"; import { AuthManager } from "../../utils/auth-manager.js"; +import { FieldCrypto } from "../../utils/field-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { authLogger } from "../../utils/logger.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; @@ -28,6 +29,7 @@ type TotpUserRecord = typeof users.$inferSelect; export async function verifyTotpReauth( userRecord: TotpUserRecord, credential: string, + userDataKey?: Buffer | null, ): Promise { if (!userRecord.isOidc && userRecord.passwordHash) { const passwordMatch = await bcrypt.compare( @@ -40,22 +42,41 @@ export async function verifyTotpReauth( } if (userRecord.totpSecret) { - const totpMatch = speakeasy.totp.verify({ - secret: userRecord.totpSecret, - encoding: "base32", - token: credential, - window: 2, - }); - if (totpMatch) { - return true; + const totpSecret = userDataKey + ? LazyFieldEncryption.safeGetFieldValue( + userRecord.totpSecret, + userDataKey, + userRecord.id, + "totpSecret", + ) + : userRecord.totpSecret; + + if (totpSecret) { + const totpMatch = speakeasy.totp.verify({ + secret: totpSecret, + encoding: "base32", + token: credential, + window: 2, + }); + if (totpMatch) { + return true; + } } } + const rawBackupCodes = + userDataKey && userRecord.totpBackupCodes + ? LazyFieldEncryption.safeGetFieldValue( + userRecord.totpBackupCodes, + userDataKey, + userRecord.id, + "totpBackupCodes", + ) + : userRecord.totpBackupCodes; + let backupCodes: unknown = []; try { - backupCodes = userRecord.totpBackupCodes - ? JSON.parse(userRecord.totpBackupCodes) - : []; + backupCodes = rawBackupCodes ? JSON.parse(rawBackupCodes) : []; } catch { backupCodes = []; } @@ -63,9 +84,18 @@ export async function verifyTotpReauth( const backupIndex = backupCodes.indexOf(credential); if (backupIndex !== -1) { backupCodes.splice(backupIndex, 1); + const updatedJson = JSON.stringify(backupCodes); + const storedValue = userDataKey + ? FieldCrypto.encryptField( + updatedJson, + userDataKey, + userRecord.id, + "totpBackupCodes", + ) + : updatedJson; await db .update(users) - .set({ totpBackupCodes: JSON.stringify(backupCodes) }) + .set({ totpBackupCodes: storedValue }) .where(eq(users.id, userRecord.id)); return true; } @@ -172,6 +202,21 @@ export function registerUserTotpRoutes( } try { + const passwordLoginRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'allow_password_login'", + ) + .get() as { value: string } | undefined; + const passwordLoginAllowed = passwordLoginRow + ? passwordLoginRow.value === "true" + : true; + if (!passwordLoginAllowed) { + return res.status(409).json({ + error: + "Cannot enable 2FA while password login is disabled. Enable password login first.", + }); + } + const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); @@ -187,8 +232,18 @@ export function registerUserTotpRoutes( return res.status(400).json({ error: "TOTP setup not initiated" }); } + const userDataKey = authManager.getUserDataKey(userId); + const totpSecret = userDataKey + ? LazyFieldEncryption.safeGetFieldValue( + userRecord.totpSecret, + userDataKey, + userId, + "totpSecret", + ) + : userRecord.totpSecret; + const verified = speakeasy.totp.verify({ - secret: userRecord.totpSecret, + secret: totpSecret, encoding: "base32", token: totp_code, window: 2, @@ -202,11 +257,21 @@ export function registerUserTotpRoutes( Math.random().toString(36).substring(2, 10).toUpperCase(), ); + const backupCodesJson = JSON.stringify(backupCodes); + const storedBackupCodes = userDataKey + ? FieldCrypto.encryptField( + backupCodesJson, + userDataKey, + userId, + "totpBackupCodes", + ) + : backupCodesJson; + await db .update(users) .set({ totpEnabled: true, - totpBackupCodes: JSON.stringify(backupCodes), + totpBackupCodes: storedBackupCodes, }) .where(eq(users.id, userId)); @@ -297,7 +362,12 @@ export function registerUserTotpRoutes( return res.status(400).json({ error: "TOTP is not enabled" }); } - const verified = await verifyTotpReauth(userRecord, credential); + const userDataKey = authManager.getUserDataKey(userId); + const verified = await verifyTotpReauth( + userRecord, + credential, + userDataKey, + ); if (!verified) { return res .status(401) @@ -378,7 +448,12 @@ export function registerUserTotpRoutes( return res.status(400).json({ error: "TOTP is not enabled" }); } - const verified = await verifyTotpReauth(userRecord, credential); + const userDataKey = authManager.getUserDataKey(userId); + const verified = await verifyTotpReauth( + userRecord, + credential, + userDataKey, + ); if (!verified) { return res .status(401) @@ -389,9 +464,19 @@ export function registerUserTotpRoutes( Math.random().toString(36).substring(2, 10).toUpperCase(), ); + const backupCodesJson = JSON.stringify(backupCodes); + const storedBackupCodes = userDataKey + ? FieldCrypto.encryptField( + backupCodesJson, + userDataKey, + userId, + "totpBackupCodes", + ) + : backupCodesJson; + await db .update(users) - .set({ totpBackupCodes: JSON.stringify(backupCodes) }) + .set({ totpBackupCodes: storedBackupCodes }) .where(eq(users.id, userId)); res.json({ backup_codes: backupCodes }); diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 9df534c77..b2843cc02 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; import { users, settings, roles, userRoles } from "../db/schema.js"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import type { Request, Response } from "express"; @@ -972,7 +972,6 @@ router.get("/oidc/callback", async (req, res) => { headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", - Authorization: `Basic ${Buffer.from(`${encodeURIComponent(config.client_id)}:${encodeURIComponent(config.client_secret)}`).toString("base64")}`, }, body: new URLSearchParams({ grant_type: "authorization_code", @@ -1367,6 +1366,39 @@ router.get("/oidc/callback", async (req, res) => { .set({ isAdmin: shouldBeAdmin }) .where(eq(users.id, userRecord.id)); userRecord.isAdmin = shouldBeAdmin; + try { + const newRoleName = shouldBeAdmin ? "admin" : "user"; + const oldRoleName = shouldBeAdmin ? "user" : "admin"; + const newRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, newRoleName)) + .limit(1); + const oldRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, oldRoleName)) + .limit(1); + if (oldRole.length > 0) { + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, userRecord.id), + eq(userRoles.roleId, oldRole[0].id), + ), + ); + } + if (newRole.length > 0) { + await db.insert(userRoles).values({ + userId: userRecord.id, + roleId: newRole[0].id, + grantedBy: userRecord.id, + }); + } + } catch { + /* non-fatal */ + } authLogger.info("OIDC admin status synced", { operation: "oidc_admin_group_sync", userId: userRecord.id, @@ -2095,6 +2127,17 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { if (typeof allowed !== "boolean") { return res.status(400).json({ error: "Invalid value for allowed" }); } + if (!allowed) { + const totpRow = db.$client + .prepare("SELECT COUNT(*) as count FROM users WHERE totp_enabled = 1") + .get() as { count?: number }; + if ((totpRow?.count || 0) > 0) { + return res.status(409).json({ + error: + "Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.", + }); + } + } db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)", diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts index b7ac567a8..fcb58c2be 100644 --- a/src/backend/guacamole/routes.ts +++ b/src/backend/guacamole/routes.ts @@ -67,9 +67,15 @@ router.use(authManager.createAuthMiddleware()); */ router.post("/token", async (req, res) => { try { - const { type, hostname, port, username, password, domain, ...options } = + const { type, hostname, port, username, password, domain, ...rawOptions } = req.body; + // Strip "auto" sentinel values before forwarding to guacd + const options: Record = {}; + for (const [key, value] of Object.entries(rawOptions)) { + if (value !== "auto") options[key] = value; + } + if (!type || !hostname) { return res .status(400) @@ -261,6 +267,14 @@ router.post( } } + // Strip "auto" sentinel values — these mean "use guacd default" in the UI + // but guacd doesn't recognise "auto" as a valid parameter value. + for (const key of Object.keys(guacConfig)) { + if (guacConfig[key] === "auto") { + delete guacConfig[key]; + } + } + if (guacConfig.dpi != null) { const parsed = parseInt(String(guacConfig.dpi), 10); guacConfig.dpi = diff --git a/src/backend/ssh/auth-manager.ts b/src/backend/ssh/auth-manager.ts index e79e2db87..0d14ed7d3 100644 --- a/src/backend/ssh/auth-manager.ts +++ b/src/backend/ssh/auth-manager.ts @@ -283,6 +283,11 @@ export class SSHAuthManager { finish: (responses: string[]) => void, resolvedCredentials: ResolvedCredentials, ): void { + if (resolvedCredentials.authType === "warpgate") { + finish(prompts.map(() => "")); + return; + } + const hasStoredPassword = resolvedCredentials.password && resolvedCredentials.authType !== "none"; @@ -290,19 +295,34 @@ export class SSHAuthManager { /password/i.test(p.prompt), ); - if (!hasStoredPassword && passwordPromptIndex !== -1) { + // Find the first prompt we can't auto-answer. This handles DUO/PAM challenges + // that don't say "password" (e.g. "Passcode or option (1-N):"). + const firstUnansweredIndex = prompts.findIndex((p) => { + if (/password/i.test(p.prompt) && hasStoredPassword) return false; + return true; + }); + + if (firstUnansweredIndex !== -1) { if (this.context.keyboardInteractiveResponded) { return; } this.context.keyboardInteractiveResponded = true; + const promptIndex = + passwordPromptIndex !== -1 && !hasStoredPassword + ? passwordPromptIndex + : firstUnansweredIndex; + this.context.keyboardInteractiveFinish = (userResponses: string[]) => { const userInput = (userResponses[0] || "").trim(); const responses = prompts.map((p, index) => { - if (index === passwordPromptIndex) { + if (index === promptIndex) { return userInput; } + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } return ""; }); @@ -335,7 +355,7 @@ export class SSHAuthManager { this.context.ws.send( JSON.stringify({ type: "password_required", - prompt: prompts[passwordPromptIndex].prompt, + prompt: prompts[promptIndex].prompt, }), ); return; diff --git a/src/backend/ssh/docker-container-routes.ts b/src/backend/ssh/docker-container-routes.ts index f9c71e50a..d6c8dc8d4 100644 --- a/src/backend/ssh/docker-container-routes.ts +++ b/src/backend/ssh/docker-container-routes.ts @@ -8,6 +8,7 @@ type DockerSession = { lastActive: number; activeOperations: number; hostId?: number; + isWindows?: boolean; }; type PendingDockerTotpSession = unknown; @@ -93,7 +94,10 @@ export function registerDockerContainerRoutes( try { const allFlag = all ? "-a " : ""; - const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`; + const formatStr = session.isWindows + ? `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"` + : `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `; + const command = `docker ps ${allFlag}--format ${formatStr}`; const output = await executeDockerCommand( session, @@ -916,7 +920,7 @@ export function registerDockerContainerRoutes( session.activeOperations++; try { - let command = `docker logs ${containerId} 2>&1`; + let command = `docker logs ${containerId}`; if (tail && tail > 0) { command += ` --tail ${Math.floor(tail)}`; @@ -934,6 +938,8 @@ export function registerDockerContainerRoutes( command += ` --until ${until}`; } + command += " 2>&1"; + const logs = await executeDockerCommand( session, command, @@ -1026,7 +1032,10 @@ export function registerDockerContainerRoutes( session.activeOperations++; try { - const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`; + const statsFormatStr = session.isWindows + ? `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"` + : `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `; + const command = `docker stats ${containerId} --no-stream --format ${statsFormatStr}`; const output = await executeDockerCommand( session, diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index a3cf1ec6e..8523e6206 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -44,6 +44,7 @@ interface SSHSession { activeOperations: number; hostId?: number; userId?: string; + isWindows?: boolean; } interface PendingTOTPSession { @@ -852,9 +853,10 @@ app.post("/docker/ssh/connect", async (req, res) => { if ( resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" + resolvedCredentials.authType === "tailscale" || + resolvedCredentials.authType === "warpgate" ) { - // Tailscale SSH and "none" auth: no credentials needed + // Tailscale SSH, "none", and Warpgate auth: no static credentials } else if (resolvedCredentials.authType === "password") { if (resolvedCredentials.password) { config.password = resolvedCredentials.password; @@ -1038,7 +1040,7 @@ app.post("/docker/ssh/connect", async (req, res) => { ), ); - sshSessions[sessionId] = { + const session: SSHSession = { client, isConnected: true, lastActive: Date.now(), @@ -1047,8 +1049,24 @@ app.post("/docker/ssh/connect", async (req, res) => { userId, }; + sshSessions[sessionId] = session; scheduleSessionCleanup(sessionId); + client.exec("ver", (err, stream) => { + if (!err && stream) { + let output = ""; + stream.on("data", (d: Buffer) => { + output += d.toString(); + }); + stream.on("close", () => { + if (output.toLowerCase().includes("windows")) { + session.isWindows = true; + } + }); + stream.stderr.on("data", () => {}); + } + }); + res.json({ success: true, message: "SSH connection established", @@ -1313,6 +1331,11 @@ app.post("/docker/ssh/connect", async (req, res) => { /password/i.test(p.prompt), ); + if (resolvedCredentials.authType === "warpgate") { + finish(prompts.map(() => "")); + return; + } + if ( (resolvedCredentials.authType === "none" || resolvedCredentials.authType === "tailscale") && @@ -2144,7 +2167,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => { try { await executeDockerCommand( session, - "docker ps >/dev/null 2>&1", + "docker ps", sessionId, userId, session.hostId, diff --git a/src/backend/ssh/file-manager-operation-routes.ts b/src/backend/ssh/file-manager-operation-routes.ts index c16536806..3cbf25eea 100644 --- a/src/backend/ssh/file-manager-operation-routes.ts +++ b/src/backend/ssh/file-manager-operation-routes.ts @@ -6,6 +6,7 @@ import { execWithSudo, type SSHSession, } from "./file-manager-session.js"; +import { isWindowsSftpPath } from "./transfer-paths.js"; type FileOperationRoutesDeps = { sshSessions: Record; @@ -373,11 +374,23 @@ export function registerFileOperationRoutes( type: isDirectory ? "directory" : "file", }); sshConn.lastActive = Date.now(); - const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); - const deleteCommand = isDirectory - ? `rm -rf '${escapedPath}'` - : `rm -f '${escapedPath}'`; + const isWindowsPath = isWindowsSftpPath(itemPath); + let deleteCommand: string; + if (isWindowsPath) { + const winPath = itemPath + .replace(/\//g, "\\") + .replace(/^\\([A-Za-z]:\\)/, "$1"); + const escapedWinPath = winPath.replace(/"/g, '""'); + deleteCommand = isDirectory + ? `rd /s /q "${escapedWinPath}"` + : `del /f /q "${escapedWinPath}"`; + } else { + const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); + deleteCommand = isDirectory + ? `rm -rf '${escapedPath}'` + : `rm -f '${escapedPath}'`; + } const executeDelete = (useSudo: boolean): Promise => { return new Promise((resolve) => { @@ -465,10 +478,6 @@ export function registerFileOperationRoutes( }, }); } else { - // The shell didn't echo our SUCCESS sentinel. This happens on - // non-POSIX shells (e.g. Windows PowerShell, where `rm -f` is - // not supported) and the command can exit 0 while doing nothing. - // Surface whatever the shell produced so the failure isn't silent. const detail = errorData.trim() || outputData.trim() || diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 5b06c46f5..5562cc9f0 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -132,6 +132,7 @@ async function buildDedicatedTransferConnectConfig( client: SSHClient, ): Promise> { const { ip, port, username } = host; + const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(host.id); const config: Record = { host: ip?.replace(/^\[|\]$/g, "") || ip, port, @@ -149,6 +150,7 @@ async function buildDedicatedTransferConnectConfig( null, userId, false, + preloadedHostData, ), env: { TERM: "xterm-256color", @@ -220,7 +222,11 @@ async function buildDedicatedTransferConnectConfig( token, username, ); - } else if (authType !== "none" && authType !== "tailscale") { + } else if ( + authType !== "none" && + authType !== "tailscale" && + authType !== "warpgate" + ) { throw new Error(`Unsupported auth type for transfer: ${authType}`); } @@ -726,6 +732,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { let resolvedPort = port; let resolvedUsername = username; let resolvedJumpHosts = jumpHosts; + let resolvedUseSocks5 = useSocks5; + let resolvedSocks5Host = socks5Host; + let resolvedSocks5Port = socks5Port; + let resolvedSocks5Username = socks5Username; + let resolvedSocks5Password = socks5Password; + let resolvedSocks5ProxyChain = socks5ProxyChain; if (hostId && userId && !password && !sshKey) { try { const { resolveHostById } = await import("./host-resolver.js"); @@ -743,6 +755,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }; hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; + if (resolvedHost.useSocks5) { + resolvedUseSocks5 = resolvedHost.useSocks5; + resolvedSocks5Host = resolvedHost.socks5Host; + resolvedSocks5Port = resolvedHost.socks5Port; + resolvedSocks5Username = resolvedHost.socks5Username; + resolvedSocks5Password = resolvedHost.socks5Password; + resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain; + } if ( (!resolvedJumpHosts || resolvedJumpHosts.length === 0) && resolvedHost.jumpHosts && @@ -790,6 +810,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }; hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; + if (resolvedHost.useSocks5) { + resolvedUseSocks5 = resolvedHost.useSocks5; + resolvedSocks5Host = resolvedHost.socks5Host; + resolvedSocks5Port = resolvedHost.socks5Port; + resolvedSocks5Username = resolvedHost.socks5Username; + resolvedSocks5Password = resolvedHost.socks5Password; + resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain; + } if ( (!resolvedJumpHosts || resolvedJumpHosts.length === 0) && resolvedHost.jumpHosts && @@ -822,6 +850,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } } + const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(hostId); const config: Record = { host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp, port: resolvedPort, @@ -829,10 +858,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { tryKeyboard: true, keepaliveInterval: typeof hostKeepaliveInterval === "number" - ? hostKeepaliveInterval * 1000 + ? Math.max(5000, hostKeepaliveInterval * 1000) : 60000, keepaliveCountMax: - typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5, + typeof hostKeepaliveCountMax === "number" + ? Math.max(1, hostKeepaliveCountMax) + : 5, readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, @@ -843,6 +874,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { null, userId, false, + preloadedHostData, ), env: { TERM: "xterm-256color", @@ -1015,7 +1047,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } } else if ( resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" + resolvedCredentials.authType === "tailscale" || + resolvedCredentials.authType === "warpgate" ) { connectionLogs.push( createConnectionLog( @@ -1266,6 +1299,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } if ( + err.message.includes("Cannot parse privateKey") && + err.message.includes("no passphrase") + ) { + res.json({ + status: "passphrase_required", + connectionLogs, + }); + } else if ( (resolvedCredentials.authType === "none" || resolvedCredentials.authType === "tailscale") && (err.message.includes("authentication") || @@ -1426,12 +1467,18 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { const hasStoredPassword = resolvedCredentials.password && resolvedCredentials.authType !== "none" && - resolvedCredentials.authType !== "tailscale"; + resolvedCredentials.authType !== "tailscale" && + resolvedCredentials.authType !== "warpgate"; const passwordPromptIndex = prompts.findIndex((p) => /password/i.test(p.prompt), ); + if (resolvedCredentials.authType === "warpgate") { + finish(prompts.map(() => "")); + return; + } + if ( (resolvedCredentials.authType === "none" || resolvedCredentials.authType === "tailscale") && @@ -1512,16 +1559,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { ); const proxyConfig: SOCKS5Config | null = - useSocks5 && - (socks5Host || - (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0)) + resolvedUseSocks5 && + (resolvedSocks5Host || + (resolvedSocks5ProxyChain && + (resolvedSocks5ProxyChain as ProxyNode[]).length > 0)) ? { - useSocks5, - socks5Host, - socks5Port, - socks5Username, - socks5Password, - socks5ProxyChain: socks5ProxyChain as ProxyNode[], + useSocks5: resolvedUseSocks5, + socks5Host: resolvedSocks5Host, + socks5Port: resolvedSocks5Port, + socks5Username: resolvedSocks5Username, + socks5Password: resolvedSocks5Password, + socks5ProxyChain: resolvedSocks5ProxyChain as ProxyNode[], } : null; @@ -1571,7 +1619,37 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }); } + let forwardOutDone = false; + const forwardOutTimeout = setTimeout(() => { + if (!forwardOutDone) { + forwardOutDone = true; + fileLogger.error("Timeout waiting for jump host forwardOut", { + operation: "file_jump_forward_timeout", + sessionId, + hostId, + ip, + port, + }); + connectionLogs.push( + createConnectionLog( + "error", + "jump", + "Timed out waiting for jump host tunnel to target host", + ), + ); + jumpClient.end(); + res.status(500).json({ + error: "Jump host tunnel timed out", + connectionLogs, + }); + } + }, 30000); + jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { + if (forwardOutDone) return; + forwardOutDone = true; + clearTimeout(forwardOutTimeout); + if (err) { fileLogger.error("Failed to forward through jump host", err, { operation: "file_jump_forward", diff --git a/src/backend/ssh/host-key-verifier.ts b/src/backend/ssh/host-key-verifier.ts index 8edf81b3c..7fae45c74 100644 --- a/src/backend/ssh/host-key-verifier.ts +++ b/src/backend/ssh/host-key-verifier.ts @@ -21,6 +21,39 @@ interface VerificationResponse { } export class SSHHostKeyVerifier { + /** + * Pre-fetches the host record from the database so the verifier callback + * during SSH key exchange doesn't need to do an async DB query. This keeps + * the key exchange critical path fast, avoiding LoginGraceTime expiry on + * the remote server (especially important for jump-host tunneled connections). + */ + static async preloadHostData( + hostId: number | null, + ): Promise<{ + hostKeyFingerprint: string | null; + hostKeyType: string | null; + hostKeyAlgorithm: string | null; + hostKeyChangedCount: number | null; + name: string | null; + } | null> { + if (!hostId) return null; + try { + const host = await db.query.hosts.findFirst({ + where: eq(hosts.id, hostId), + columns: { + hostKeyFingerprint: true, + hostKeyType: true, + hostKeyAlgorithm: true, + hostKeyChangedCount: true, + name: true, + }, + }); + return host ?? null; + } catch { + return null; + } + } + static async createHostVerifier( hostId: number | null, ip: string, @@ -28,6 +61,9 @@ export class SSHHostKeyVerifier { ws: WebSocket | null, userId: string, isJumpHost: boolean = false, + preloadedHost?: Awaited< + ReturnType + >, ): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> { return (hostkey: Buffer, verify: (valid: boolean) => void): void => { (async () => { @@ -52,9 +88,10 @@ export class SSHHostKeyVerifier { return; } - const host = await db.query.hosts.findFirst({ - where: eq(hosts.id, hostId), - }); + const host = + preloadedHost !== undefined + ? preloadedHost + : await db.query.hosts.findFirst({ where: eq(hosts.id, hostId) }); if (!host) { sshLogger.warn( @@ -141,13 +178,6 @@ export class SSHHostKeyVerifier { } if (host.hostKeyFingerprint === fingerprint) { - await db - .update(hosts) - .set({ - hostKeyLastVerified: new Date().toISOString(), - }) - .where(eq(hosts.id, hostId)); - sshLogger.info("Host key verified successfully", { operation: "host_key_verified", hostId, @@ -158,7 +188,18 @@ export class SSHHostKeyVerifier { userId, }); + // Verify first, then update the timestamp asynchronously so the + // DB write doesn't delay the SSH key exchange critical path. verify(true); + db.update(hosts) + .set({ hostKeyLastVerified: new Date().toISOString() }) + .where(eq(hosts.id, hostId)) + .catch((err) => { + sshLogger.error("Failed to update hostKeyLastVerified", err, { + operation: "host_key_update_timestamp", + hostId, + }); + }); return; } diff --git a/src/backend/ssh/host-metrics.ts b/src/backend/ssh/host-metrics.ts index 880c15f45..7b5152ec1 100644 --- a/src/backend/ssh/host-metrics.ts +++ b/src/backend/ssh/host-metrics.ts @@ -1028,7 +1028,11 @@ async function buildSshConfig( cause: keyError, }); } - } else if (host.authType === "none" || host.authType === "tailscale") { + } else if ( + host.authType === "none" || + host.authType === "tailscale" || + host.authType === "warpgate" + ) { // no credentials needed } else if (host.authType === "opkssh") { // cert auth setup happens in createSshFactory (needs client instance) diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 9345725b0..5f3b42fa5 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -30,6 +30,7 @@ import { waitForTmuxSession, } from "./tmux-helper.js"; import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js"; +import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js"; interface ConnectToHostData { cols: number; @@ -187,8 +188,6 @@ wss.on("connection", async (ws: WebSocket, req) => { let isConnecting = false; let isConnected = false; let isCleaningUp = false; - let cwdPending = false; - let cwdBuffer = ""; let isShellInitializing = false; let warpgateAuthPromptSent = false; let warpgateAuthTimeout: NodeJS.Timeout | null = null; @@ -446,15 +445,31 @@ wss.on("connection", async (ws: WebSocket, req) => { break; case "get_cwd": { - const activeStream = - sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; - if (!activeStream) { + const activeConn = + sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn; + if (!activeConn) { ws.send(JSON.stringify({ type: "cwd", path: "/" })); break; } - cwdPending = true; - cwdBuffer = ""; - activeStream.write('\x15a=TERMIX_CWD; echo "$a:$(pwd)"\r'); + activeConn.exec("pwd", (err, execStream) => { + if (err) { + ws.send(JSON.stringify({ type: "cwd", path: "/" })); + return; + } + let stdout = ""; + execStream.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf-8"); + }); + execStream.stderr.on("data", () => {}); + execStream.on("close", () => { + const cwd = stdout.trim() || "/"; + const attachedWs = + sessionManager.getSession(currentSessionId)?.attachedWs ?? ws; + if (attachedWs.readyState === WebSocket.OPEN) { + attachedWs.send(JSON.stringify({ type: "cwd", path: cwd })); + } + }); + }); break; } @@ -990,7 +1005,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ); } - if (!hostConfig.useSocks5 && resolvedHostData.useSocks5) { + if (resolvedHostData.useSocks5) { hostConfig.useSocks5 = resolvedHostData.useSocks5; hostConfig.socks5Host = resolvedHostData.socks5Host; hostConfig.socks5Port = resolvedHostData.socks5Port; @@ -1134,15 +1149,19 @@ wss.on("connection", async (ws: WebSocket, req) => { !existingSession.sshStream.destroyed && existingSession.sshConn !== sshConn ) { + const reusedSessionId = currentSessionId; sshLogger.info( "Reusing existing live session after duplicate connectToHost, closing new SSH conn", { operation: "terminal_reuse_existing_session", - sessionId: currentSessionId, + sessionId: reusedSessionId, tabInstanceId, userId, }, ); + // Null out currentSessionId before ending the duplicate connection so + // the sshConn "close" handler does not destroy the reused session. + currentSessionId = null; try { sshConn?.end(); } catch { @@ -1154,7 +1173,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn = existingSession.sshConn; isConnecting = false; isConnected = true; - sessionManager.attachWs(currentSessionId, userId, ws, tabInstanceId); + sessionManager.attachWs(reusedSessionId, userId, ws, tabInstanceId); const buffered = sessionManager.getBuffer(existingSession); if (buffered) { @@ -1163,13 +1182,13 @@ wss.on("connection", async (ws: WebSocket, req) => { ws.send( JSON.stringify({ type: "sessionCreated", - sessionId: currentSessionId, + sessionId: reusedSessionId, }), ); ws.send( JSON.stringify({ type: "sessionAttached", - sessionId: currentSessionId, + sessionId: reusedSessionId, }), ); ws.send( @@ -1350,44 +1369,9 @@ wss.on("connection", async (ws: WebSocket, req) => { const boundSessionId = currentSessionId; - const CWD_SENTINEL = "TERMIX_CWD:"; - stream.on("data", (data: Buffer) => { try { - let utf8String = data.toString("utf-8"); - - if (cwdPending) { - cwdBuffer += utf8String; - const sentinelIdx = cwdBuffer.indexOf(CWD_SENTINEL); - if (sentinelIdx !== -1) { - const afterSentinel = cwdBuffer.slice( - sentinelIdx + CWD_SENTINEL.length, - ); - const newlineIdx = afterSentinel.search(/[\r\n]/); - if (newlineIdx !== -1) { - const cwd = - afterSentinel.slice(0, newlineIdx).trim() || "/"; - cwdPending = false; - // Strip the sentinel line from output sent to terminal - const beforeSentinel = cwdBuffer.slice(0, sentinelIdx); - const afterNewline = afterSentinel.slice(newlineIdx); - utf8String = beforeSentinel + afterNewline; - cwdBuffer = ""; - const attachedWs = - sessionManager.getSession(boundSessionId)?.attachedWs ?? - ws; - if (attachedWs.readyState === WebSocket.OPEN) { - attachedWs.send( - JSON.stringify({ type: "cwd", path: cwd }), - ); - } - } else { - return; - } - } else { - return; - } - } + const utf8String = data.toString("utf-8"); if (!utf8String) return; @@ -1475,7 +1459,14 @@ wss.on("connection", async (ws: WebSocket, req) => { const runPostShellCommands = (delay: number) => { setTimeout(() => { if (initialPath && initialPath.trim() !== "") { - const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`; + let cdCommand: string; + if (isWindowsSftpPath(initialPath)) { + const winPath = sftpPathToLocalPath(initialPath); + const escaped = winPath.replace(/"/g, '""'); + cdCommand = `cd "${escaped}"\r`; + } else { + cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`; + } stream.write(cdCommand); } if (executeCommand && executeCommand.trim() !== "") { @@ -2008,19 +1999,24 @@ wss.on("connection", async (ws: WebSocket, req) => { const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval; const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax; + // Pre-fetch the stored host key before connect so the verifier callback + // runs synchronously during SSH key exchange, avoiding LoginGraceTime + // expiry on slow connections (especially through jump host tunnels). + const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id); + const connectConfig: Record = { host: ip, port, username, - tryKeyboard: - resolvedCredentials.authType !== "none" && - resolvedCredentials.authType !== "tailscale", + tryKeyboard: resolvedCredentials.authType !== "tailscale", keepaliveInterval: typeof hostKeepaliveInterval === "number" - ? hostKeepaliveInterval * 1000 + ? Math.max(5000, hostKeepaliveInterval * 1000) : 30000, keepaliveCountMax: - typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3, + typeof hostKeepaliveCountMax === "number" + ? Math.max(1, hostKeepaliveCountMax) + : 5, readyTimeout: 120000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, @@ -2032,6 +2028,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ws, userId, false, + preloadedHostData, ), env: { TERM: "xterm-256color", @@ -2087,9 +2084,10 @@ wss.on("connection", async (ws: WebSocket, req) => { if ( resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" + resolvedCredentials.authType === "tailscale" || + resolvedCredentials.authType === "warpgate" ) { - // Tailscale SSH and "none" auth: daemon handles authorization, no credentials needed + // Tailscale SSH, "none", and Warpgate auth: no static credentials; Warpgate handles auth via keyboard-interactive } else if (resolvedCredentials.authType === "password") { if (!resolvedCredentials.password) { sshLogger.error( diff --git a/src/types/index.ts b/src/types/index.ts index a78a6af79..bdeae2178 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -100,7 +100,14 @@ export interface Host { folder: string; tags: string[]; pin: boolean; - authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; + authType: + | "password" + | "key" + | "credential" + | "none" + | "opkssh" + | "tailscale" + | "warpgate"; password?: string; key?: string; keyPassword?: string; @@ -210,7 +217,14 @@ export interface HostData { folder?: string; tags?: string[]; pin?: boolean; - authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; + authType: + | "password" + | "key" + | "credential" + | "none" + | "opkssh" + | "tailscale" + | "warpgate"; password?: string; key?: File | null; keyPassword?: string; diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 308d6dbef..236f8f3d4 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -10,7 +10,14 @@ export type Host = { ram: number | null; lastAccess: string; tags?: string[]; - authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; + authType: + | "password" + | "key" + | "credential" + | "none" + | "opkssh" + | "tailscale" + | "warpgate"; credentialId?: string; overrideCredentialUsername?: boolean; password?: string; @@ -209,6 +216,7 @@ export type Tab = { reconnect?: () => void; fit?: () => void; notifyResize?: () => void; + getApplicationCursorKeysMode?: () => boolean; } | null>; }; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 53c52f67b..dfe86556a 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -80,7 +80,7 @@ function sshHostToHost(h: SSHHostWithStatus): Host { enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType), enableTerminal: h.enableTerminal ?? true, enableTunnel: h.enableTunnel ?? false, - enableFileManager: h.enableFileManager ?? false, + enableFileManager: h.enableFileManager ?? true, enableDocker: h.enableDocker ?? false, enableProxmox: h.enableProxmox ?? false, enableTmuxMonitor: h.enableTmuxMonitor ?? false, // --- tmux-monitor --- diff --git a/src/ui/api/ssh-file-operations-api.ts b/src/ui/api/ssh-file-operations-api.ts index 4d284638e..3206c2282 100644 --- a/src/ui/api/ssh-file-operations-api.ts +++ b/src/ui/api/ssh-file-operations-api.ts @@ -51,10 +51,11 @@ export async function connectSSH( }, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/connect", { - sessionId, - ...config, - }); + const response = await fileManagerApi.post( + "/ssh/connect", + { sessionId, ...config }, + { timeout: 120000 }, + ); return response.data; } catch (error: unknown) { if ( diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 36233d4a1..733638462 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -732,6 +732,29 @@ export function Auth({ onLogin }: AuthProps) { const loadingKey = providerId ?? -1; setProviderLoading((prev) => ({ ...prev, [loadingKey]: true })); try { + if (isInElectronWebView()) { + // Inside the Electron iframe: delegate OIDC to the parent window so + // the system browser opens instead of navigating the iframe (which + // would break captcha stages like Cloudflare Turnstile). + const callbackPort = 17832 + Math.floor(Math.random() * 100); + const authResponse = await getOIDCAuthorizeUrl( + rememberMe, + callbackPort, + providerId, + ); + const { auth_url: authUrl } = authResponse; + if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); + window.parent.postMessage( + { + type: "OIDC_SYSTEM_BROWSER_AUTH", + source: "oidc_request", + authUrl, + callbackPort, + }, + "*", + ); + return; + } if (isElectron()) { const electronAPI = ( window as unknown as { diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx index 24224f892..8bd003e26 100644 --- a/src/ui/auth/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -63,13 +63,43 @@ export function ElectronLoginForm({ try { if (event.source !== iframeRef.current?.contentWindow) return; if (!event.data || typeof event.data !== "object") return; - const { type, platform, source, token } = event.data; + const { type, platform, source, token, authUrl, callbackPort } = + event.data; + if ( type === "AUTH_SUCCESS" && platform === "desktop" && AUTH_MESSAGE_SOURCES.has(source) ) { await handleAuthSuccess(token ?? null); + return; + } + + // OIDC login requested from inside the iframe — open the system browser + // so captcha stages (e.g. Cloudflare Turnstile) render correctly. + if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) { + const electronAPI = ( + window as unknown as { + electronAPI?: { + oidcSystemBrowserAuth?: ( + url: string, + port: number, + ) => Promise<{ + success: boolean; + token?: string; + error?: string; + }>; + }; + } + ).electronAPI; + if (!electronAPI?.oidcSystemBrowserAuth) return; + const result = await electronAPI.oidcSystemBrowserAuth( + authUrl, + callbackPort, + ); + if (result.success && result.token) { + await handleAuthSuccess(result.token); + } } } catch { // ignore diff --git a/src/ui/auth/LoginPage.tsx b/src/ui/auth/LoginPage.tsx index 34e6faa68..1da26040c 100644 --- a/src/ui/auth/LoginPage.tsx +++ b/src/ui/auth/LoginPage.tsx @@ -683,8 +683,19 @@ export function Auth({ setLdapLoading(true); try { await ldapLogin(providerId, ldapUsername, ldapPassword, rememberMe); - const userInfo = await getUserInfo(); - onLogin(userInfo); + const meRes = await getUserInfo(); + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + toast.success(t("messages.loginSuccess")); } catch (err: unknown) { const error = err as { response?: { data?: { error?: string } }; @@ -699,7 +710,18 @@ export function Auth({ setLdapLoading(false); } }, - [ldapUsername, ldapPassword, rememberMe, onLogin, t], + [ + ldapUsername, + ldapPassword, + rememberMe, + onAuthSuccess, + setLoggedIn, + setIsAdmin, + setUsername, + setUserId, + setDbError, + t, + ], ); useEffect(() => { @@ -1515,106 +1537,122 @@ export function Auth({ className="flex flex-col gap-5" onSubmit={handleSubmit} > -
- - - setLocalUsername(e.target.value) - } - disabled={loading || loggedIn} - /> -
-
- - setPassword(e.target.value)} - disabled={loading || loggedIn} - /> -
- {tab === "login" && ( -
- - setRememberMe(checked === true) - } - disabled={loading || loggedIn} - /> -
- )} - {tab === "signup" && ( -
- - - setSignupConfirmPassword(e.target.value) - } - disabled={loading || loggedIn} - /> -
- )} - - {tab === "login" && ( - + {loading + ? Spinner + : tab === "login" + ? t("common.login") + : t("auth.signUp")} + + {tab === "login" && ( + + )} + )} {ssoProviders.length > 0 && !isElectron() && (
-
-
- - {t("auth.orContinueWith")} - -
-
+ {(passwordLoginAllowed || + firstUser || + tab === "signup") && ( +
+
+ + {t("auth.orContinueWith")} + +
+
+ )} {ssoProviders.map((provider) => { if (provider.type === "ldap") { const isExpanded = diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 9cf24b3b5..ab214bcb8 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -66,7 +66,7 @@ function sshHostToHost(h: SSHHostWithStatus): Host { macAddress: h.macAddress, enableTerminal: h.enableTerminal ?? true, enableTunnel: h.enableTunnel ?? false, - enableFileManager: h.enableFileManager ?? false, + enableFileManager: h.enableFileManager ?? true, enableDocker: h.enableDocker ?? false, enableSsh: h.connectionType === "ssh" || !h.connectionType, enableRdp: h.connectionType === "rdp", diff --git a/src/ui/features/docker/components/LogViewer.tsx b/src/ui/features/docker/components/LogViewer.tsx index 81cc4c2b5..bca7b51e7 100644 --- a/src/ui/features/docker/components/LogViewer.tsx +++ b/src/ui/features/docker/components/LogViewer.tsx @@ -204,7 +204,7 @@ export function LogViewer({
-
+
{filteredLogs.length > 0 ? ( filteredLogs.map((line, i) => { const tsEnd = line.indexOf(" ", 1); diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 432f205d6..1290b88f2 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -24,6 +24,7 @@ import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { FileManagerDialogs } from "./FileManagerDialogs.tsx"; +import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx"; import { FileManagerToolbar } from "./FileManagerToolbar.tsx"; import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx"; import { TerminalWindow } from "./components/TerminalWindow.tsx"; @@ -133,6 +134,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [authDialogReason, setAuthDialogReason] = useState< "no_keyboard" | "auth_failed" | "timeout" >("no_keyboard"); + const [showPassphraseDialog, setShowPassphraseDialog] = useState(false); const [pinnedFiles, setPinnedFiles] = useState>(new Set()); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); const [hasConnectionError, setHasConnectionError] = useState(false); @@ -416,6 +418,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { return; } + if (result?.status === "passphrase_required") { + setShowPassphraseDialog(true); + setIsLoading(false); + return; + } + setSshSessionId(sessionId); try { @@ -2120,6 +2128,85 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { if (onClose) onClose(); } + async function handlePassphraseSubmit(passphrase: string) { + if (!currentHost) return; + + try { + setIsLoading(true); + setShowPassphraseDialog(false); + + const sessionId = currentHost.id.toString(); + + const result = await connectSSH(sessionId, { + hostId: currentHost.id, + ip: currentHost.ip, + port: currentHost.port, + username: currentHost.username, + sshKey: currentHost.key, + keyPassword: passphrase, + authType: "key", + credentialId: currentHost.credentialId, + userId: currentHost.userId, + jumpHosts: currentHost.jumpHosts, + useSocks5: currentHost.useSocks5, + socks5Host: currentHost.socks5Host, + socks5Port: currentHost.socks5Port, + socks5Username: currentHost.socks5Username, + socks5Password: currentHost.socks5Password, + socks5ProxyChain: currentHost.socks5ProxyChain, + }); + + if (result?.status === "passphrase_required") { + setShowPassphraseDialog(true); + setIsLoading(false); + toast.error(t("fileManager.incorrectPassphrase")); + return; + } + + if (result?.requires_totp) { + setTotpRequired(true); + setTotpSessionId(sessionId); + setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt")); + setIsLoading(false); + return; + } + + if (result?.status === "auth_required") { + setAuthDialogReason(result.reason || "auth_failed"); + setShowAuthDialog(true); + setIsLoading(false); + return; + } + + setSshSessionId(sessionId); + + try { + const response = await listSSHFiles(sessionId, currentPath); + const files = Array.isArray(response) + ? response + : response?.files || []; + setFiles(files); + clearSelection(); + initialLoadDoneRef.current = true; + toast.success(t("fileManager.connectedSuccessfully")); + logFileManagerActivity(); + } catch (dirError: unknown) { + console.error("Failed to load initial directory:", dirError); + } + } catch (error: unknown) { + console.error("SSH connection with passphrase failed:", error); + setShowPassphraseDialog(true); + toast.error(t("fileManager.incorrectPassphrase")); + } finally { + setIsLoading(false); + } + } + + function handlePassphraseCancel() { + setShowPassphraseDialog(false); + if (onClose) onClose(); + } + function generateUniqueName( baseName: string, type: "file" | "directory", @@ -2768,6 +2855,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { /> )} + {currentHost && ( + + )} + item.type === "directory", - ); + const rootFolders = rootFiles + .filter((item: DirectoryItemData) => item.type === "directory") + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({ id: `folder-${folder.name}`, @@ -232,9 +234,11 @@ export function FileManagerSidebar({ try { const subResponse = await listSSHFiles(sshSessionId, folderPath); const subFiles = (subResponse.files || []) as DirectoryItemData[]; - const subFolders = subFiles.filter( - (item: DirectoryItemData) => item.type === "directory", - ); + const subFolders = subFiles + .filter((item: DirectoryItemData) => item.type === "directory") + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({ id: `folder-${folder.path.replace(/\//g, "-")}`, diff --git a/src/ui/features/file-manager/components/FileViewer.tsx b/src/ui/features/file-manager/components/FileViewer.tsx index 321f9d652..6ae9a8c6a 100644 --- a/src/ui/features/file-manager/components/FileViewer.tsx +++ b/src/ui/features/file-manager/components/FileViewer.tsx @@ -85,6 +85,7 @@ interface FileViewerProps { savedContent?: string; isLoading?: boolean; isEditable?: boolean; + resetKey?: number; onContentChange?: (content: string) => void; onSave?: (content: string) => void; onRevert?: () => void; @@ -265,6 +266,7 @@ export function FileViewer({ savedContent = "", isLoading = false, isEditable = false, + resetKey, onContentChange, onSave, onRevert, @@ -301,6 +303,9 @@ export function FileViewer({ if (savedContent) { setOriginalContent(savedContent); } + }, [file.name, file.path, resetKey]); + + useEffect(() => { setHasChanges(content !== savedContent); if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) { @@ -308,14 +313,7 @@ export function FileViewer({ } else { setShowLargeFileWarning(false); } - }, [ - content, - savedContent, - fileTypeInfo.type, - isLargeFile, - forceShowAsText, - file.name, - ]); + }, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]); const handleContentChange = (newContent: string) => { setEditedContent(newContent); diff --git a/src/ui/features/file-manager/components/FileWindow.tsx b/src/ui/features/file-manager/components/FileWindow.tsx index ab3ed46c5..706310490 100644 --- a/src/ui/features/file-manager/components/FileWindow.tsx +++ b/src/ui/features/file-manager/components/FileWindow.tsx @@ -82,6 +82,7 @@ export function FileWindow({ const [isLoading, setIsLoading] = useState(false); const [isEditable, setIsEditable] = useState(false); const [pendingContent, setPendingContent] = useState(""); + const [resetKey, setResetKey] = useState(0); const [mediaDimensions, setMediaDimensions] = useState< { width: number; height: number } | undefined >(); @@ -141,6 +142,7 @@ export function FileWindow({ setContent(fileContent); setPendingContent(fileContent); + setResetKey((k) => k + 1); if (!file.size) { const contentSize = new Blob([fileContent]).size; @@ -268,6 +270,7 @@ export function FileWindow({ const fileContent = response.content || ""; setContent(fileContent); setPendingContent(""); + setResetKey((k) => k + 1); if (!file.size) { const contentSize = new Blob([fileContent]).size; @@ -444,6 +447,7 @@ export function FileWindow({ content={pendingContent || content} savedContent={content} isLoading={isLoading} + resetKey={resetKey} onRevert={handleRevert} isEditable={isEditable} onContentChange={handleContentChange} diff --git a/src/ui/features/terminal/MobileTerminalKeyboard.tsx b/src/ui/features/terminal/MobileTerminalKeyboard.tsx new file mode 100644 index 000000000..534e1f248 --- /dev/null +++ b/src/ui/features/terminal/MobileTerminalKeyboard.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import type { TerminalHandle } from "./terminal-types"; + +interface MobileTerminalKeyboardProps { + terminalRef: React.RefObject; +} + +const BTN = + "flex items-center justify-center h-8 px-2.5 min-w-[2.5rem] text-xs font-medium rounded border transition-colors select-none active:scale-95"; + +const BTN_NORMAL = "border-border bg-muted/50 text-foreground hover:bg-muted"; +const BTN_ACTIVE = + "border-accent-brand/60 bg-accent-brand/15 text-accent-brand"; + +export function MobileTerminalKeyboard({ + terminalRef, +}: MobileTerminalKeyboardProps) { + const { t } = useTranslation(); + const [shiftActive, setShiftActive] = useState(false); + const [ctrlActive, setCtrlActive] = useState(false); + + function send(seq: string) { + terminalRef.current?.sendInput?.(seq); + } + + function handleSpecialKey(baseSeq: string, shiftSeq?: string) { + const seq = shiftActive && shiftSeq ? shiftSeq : baseSeq; + send(seq); + setShiftActive(false); + } + + function handleCtrlKey(key: string) { + const ctrlMap: Record = { + c: "\x03", + d: "\x04", + l: "\x0C", + u: "\x15", + z: "\x1A", + a: "\x01", + }; + const seq = ctrlMap[key.toLowerCase()]; + if (seq) send(seq); + setCtrlActive(false); + } + + function handleTab() { + if (shiftActive) { + send("\x1b[Z"); + setShiftActive(false); + } else { + send("\t"); + } + } + + return ( +
+ {/* Sticky modifiers */} + + + +
+ + {/* Tab / Shift+Tab */} + + + {/* Esc */} + + + {/* Arrow keys */} +
+ + + + + + {/* Ctrl combos row — only visible when Ctrl is active */} + {ctrlActive && ( + <> +
+ {["c", "d", "l", "u", "z", "a"].map((k) => ( + + ))} + + )} +
+ ); +} diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 7102b0138..3328a59a8 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -673,6 +673,8 @@ const TerminalInner = forwardRef( } }, refresh: () => hardRefresh(), + getApplicationCursorKeysMode: () => + terminal?.modes?.applicationCursorKeysMode ?? false, openFileManager: () => { if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send(JSON.stringify({ type: "get_cwd" })); @@ -970,6 +972,13 @@ const TerminalInner = forwardRef( } if (msg.type === "data") { if (typeof msg.data === "string") { + if (showAutocompleteRef.current) { + showAutocompleteRef.current = false; + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + } + const syntaxHighlightingEnabled = hostConfig.terminalConfig?.syntaxHighlighting !== false; @@ -982,7 +991,13 @@ const TerminalInner = forwardRef( terminal.write(outputData); const sudoPasswordPattern = - /(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required|password for [^\n]*:\s*$|Password:\s*$)/i; + /(?:\[sudo\][^\n\r]*:\s*$|sudo:[^\n\r]*password[^\n\r]*required|password for [^\n\r]*:\s*$|Password:\s*$)/im; + // Strip ANSI escape codes before testing — newer sudo versions (Ubuntu 26.04+) + // emit colored prompts with embedded escape sequences that break the regex. + const strippedData = msg.data.replace( + /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g, + "", + ); const hasSudoPw = hostConfig.terminalConfig?.sudoPassword || hostConfig.password || @@ -990,7 +1005,7 @@ const TerminalInner = forwardRef( hostConfig.hasPassword; if ( config.sudoPasswordAutoFill && - sudoPasswordPattern.test(msg.data) && + sudoPasswordPattern.test(strippedData) && hasSudoPw && !sudoPromptShownRef.current ) { @@ -1850,7 +1865,7 @@ const TerminalInner = forwardRef( fontFamily, allowTransparency: true, // MUST be set before open() convertEol: false, - macOptionIsMeta: false, + macOptionIsMeta: true, macOptionClickForcesSelection: false, rightClickSelectsWord: config.rightClickSelectsWord, fastScrollSensitivity: config.fastScrollSensitivity, @@ -2017,7 +2032,18 @@ const TerminalInner = forwardRef( return false; }; + // On macOS Electron, Tab key events can be swallowed by Chromium's focus + // traversal system before xterm.js sees them. Calling preventDefault() in + // the capture phase blocks that traversal while still allowing the event to + // reach xterm.js's internal handler (which fires our attachCustomKeyEventHandler). + const handleTabCapture = (e: KeyboardEvent) => { + if (e.key === "Tab") { + e.preventDefault(); + } + }; + element?.addEventListener("keydown", handleBackspaceMode, true); + element?.addEventListener("keydown", handleTabCapture, true); const resizeObserver = new ResizeObserver(() => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); @@ -2041,6 +2067,7 @@ const TerminalInner = forwardRef( element?.removeEventListener("mousemove", handleTmuxDragMove); element?.removeEventListener("mouseup", handleTmuxDragEnd); element?.removeEventListener("keydown", handleBackspaceMode, true); + element?.removeEventListener("keydown", handleTabCapture, true); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current); }; diff --git a/src/ui/features/terminal/terminal-types.ts b/src/ui/features/terminal/terminal-types.ts index 68f0d6815..4de17c5fe 100644 --- a/src/ui/features/terminal/terminal-types.ts +++ b/src/ui/features/terminal/terminal-types.ts @@ -24,4 +24,5 @@ export interface TerminalHandle { sendInput: (data: string) => void; notifyResize: () => void; refresh: () => void; + getApplicationCursorKeysMode: () => boolean; } diff --git a/src/ui/lib/clipboard-provider.ts b/src/ui/lib/clipboard-provider.ts index 2266f4016..d3cd1b0e6 100644 --- a/src/ui/lib/clipboard-provider.ts +++ b/src/ui/lib/clipboard-provider.ts @@ -18,9 +18,13 @@ export class RobustClipboardProvider implements IClipboardProvider { }); return; } - navigator.clipboard.writeText(text).catch(() => { + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).catch(() => { + this.pendingWrite = text; + }); + } else { this.pendingWrite = text; - }); + } } }; window.addEventListener("focus", this.focusHandler); @@ -47,7 +51,11 @@ export class RobustClipboardProvider implements IClipboardProvider { await window.electronClipboard.writeText(text); return; } - await navigator.clipboard.writeText(text); + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + this.pendingWrite = text; + } } catch { this.pendingWrite = text; } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index a7bf30889..7e08e5f42 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -339,6 +339,8 @@ "docsLink": "View docs", "opksshLabel": "OPKSSH", "opksshDesc": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.", + "warpgateLabel": "Warpgate", + "warpgateDesc": "Connect through a Warpgate SSH proxy. Termix will handle the browser-based approval flow automatically.", "tailscaleDeviceSelect": "Select Tailscale device", "tailscaleDeviceSelectPlaceholder": "Select a device...", "tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.", @@ -522,6 +524,7 @@ "credentialUpdated": "Credential updated", "credentialCreated": "Credential created", "failedToSaveCredential": "Failed to save credential", + "credentialNameRequired": "Please enter a name for the credential", "backToHosts": "Back to Hosts", "backToCredentials": "Back to Credentials", "pinned": "Pinned", @@ -1248,6 +1251,7 @@ "totpVerificationFailed": "TOTP verification failed", "warpgateVerificationFailed": "Warpgate authentication failed", "authenticationFailed": "Authentication failed", + "incorrectPassphrase": "Incorrect passphrase. Please try again.", "verificationCodePrompt": "Verification code:", "changePermissions": "Change Permissions", "currentPermissions": "Current Permissions", @@ -1684,6 +1688,7 @@ "dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.", "authenticationDisabled": "Authentication Disabled", "authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator.", + "passwordLoginDisabledDesc": "Password login is disabled. Please use an external authentication provider below.", "attemptsRemaining": "{{count}} attempts remaining" }, "hostKey": { @@ -2203,6 +2208,7 @@ "saving": "Saving...", "updateRegistrationFailed": "Failed to update registration setting", "updatePasswordLoginFailed": "Failed to update password login setting", + "cannotDisablePasswordLoginWithTotp": "Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.", "updateOidcAutoProvisionFailed": "Failed to update OIDC auto-provision setting", "updatePasswordResetFailed": "Failed to update password reset setting", "sessionTimeoutRange2": "Session timeout must be between 1 and 720 hours", @@ -2588,5 +2594,16 @@ "timeMinutes": "{{count}}m ago", "timeHours": "{{count}}h ago", "timeDays": "{{count}}d ago" + }, + "mobileKeyboard": { + "shift": "Shift", + "ctrl": "Ctrl", + "tab": "Tab", + "shiftTab": "Shift+Tab", + "esc": "Esc", + "arrowUp": "^", + "arrowDown": "v", + "arrowLeft": "<", + "arrowRight": ">" } } diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 8ac14fb5b..117d0f82c 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -20,6 +20,8 @@ import type { TerminalHandle, TerminalHostConfig, } from "@/features/terminal/Terminal"; +import { MobileTerminalKeyboard } from "@/features/terminal/MobileTerminalKeyboard"; +import { useIsMobile } from "@/hooks/use-mobile"; import { FileManager } from "@/features/file-manager/FileManager"; import { DockerManager } from "@/features/docker/DockerManager"; import { HostMetricsTab } from "@/features/host-metrics/HostMetricsTab"; @@ -135,25 +137,37 @@ function TerminalTabContent({ onCloseTab?: (id: string) => void; }) { const { previewTerminalTheme } = useTabsSafe(); + const isMobile = useIsMobile(); return ( - } - hostConfig={ - { - ...hostToSSHHost(host), - sshPort: host.sshPort ?? host.port, - instanceId: tab.instanceId ?? tab.id, - restoredSessionId: tab.restoredSessionId ?? null, - } as TerminalHostConfig - } - isVisible={isVisible} - title={label} - showTitle={false} - splitScreen={false} - onClose={() => onCloseTab?.(tab.id)} - previewTheme={previewTerminalTheme} - /> +
+
+ } + hostConfig={ + { + ...hostToSSHHost(host), + sshPort: host.sshPort ?? host.port, + instanceId: tab.instanceId ?? tab.id, + restoredSessionId: tab.restoredSessionId ?? null, + } as TerminalHostConfig + } + isVisible={isVisible} + title={label} + showTitle={false} + splitScreen={false} + onClose={() => onCloseTab?.(tab.id)} + previewTheme={previewTerminalTheme} + /> +
+ {isMobile && ( + + } + /> + )} +
); } diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index a84304688..ae80dcc94 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -297,9 +297,10 @@ export function AdminSettingsPanel() { setAllowPasswordLogin(newVal); try { await updatePasswordLoginAllowed(newVal); - } catch { + } catch (e) { setAllowPasswordLogin(!newVal); - toast.error(t("admin.updatePasswordLoginFailed")); + const msg = (e as ApiErrorLike).response?.data?.error; + toast.error(msg || t("admin.updatePasswordLoginFailed")); } } diff --git a/src/ui/sidebar/CredentialEditorView.tsx b/src/ui/sidebar/CredentialEditorView.tsx index f149c5adb..57f1ec59e 100644 --- a/src/ui/sidebar/CredentialEditorView.tsx +++ b/src/ui/sidebar/CredentialEditorView.tsx @@ -56,6 +56,10 @@ export function CredentialEditorView({ const [saving, setSaving] = useState(false); const handleSave = async () => { + if (!credForm.name.trim()) { + toast.error(t("hosts.credentialNameRequired")); + return; + } setSaving(true); try { const data = { @@ -92,8 +96,9 @@ export function CredentialEditorView({ ); window.dispatchEvent(new CustomEvent("termix:credentials-changed")); onSave(saved); - } catch { - toast.error(t("hosts.failedToSaveCredential")); + } catch (err) { + const msg = err instanceof Error ? err.message : null; + toast.error(msg || t("hosts.failedToSaveCredential")); } finally { setSaving(false); } @@ -327,7 +332,7 @@ export function CredentialEditorView({ { const file = e.target.files?.[0]; diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index 285a8274c..7d56d24fc 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -240,6 +240,7 @@ export function HostEditor({ "none", "opkssh", "tailscale", + "warpgate", ].map((m) => (
)} + {authMethod === "warpgate" && ( +
+
+ + {t("hosts.warpgateLabel")} + +
+

+ {t("hosts.warpgateDesc")} +

+
+ )} { const set = new Set(); + const addWithAncestors = (path: string) => { + const parts = path.split(" / "); + let accumulated = ""; + for (const part of parts) { + accumulated = accumulated ? `${accumulated} / ${part}` : part; + set.add(accumulated); + } + }; for (const h of hosts) { - if (h.folder) set.add(h.folder); + if (h.folder) addWithAncestors(h.folder); } - for (const path of folderMeta.keys()) set.add(path); + for (const path of folderMeta.keys()) addWithAncestors(path); return [...set]; }, [hosts, folderMeta]); diff --git a/src/ui/sidebar/HostEditorGuacamoleTabs.tsx b/src/ui/sidebar/HostEditorGuacamoleTabs.tsx index 472252861..9c04d7d9d 100644 --- a/src/ui/sidebar/HostEditorGuacamoleTabs.tsx +++ b/src/ui/sidebar/HostEditorGuacamoleTabs.tsx @@ -981,6 +981,28 @@ export function HostEditorVncTab({
+
+ + +
{ if (!viewContent) return; - try { - await navigator.clipboard.writeText(viewContent); + const ok = await copyToClipboard(viewContent); + if (ok) { setCopied(true); setTimeout(() => setCopied(false), 1500); - } catch { - toast.error("Failed to copy"); + } else { + toast.error(t("common.copyFailed")); } }; diff --git a/src/ui/sidebar/SidebarTree.tsx b/src/ui/sidebar/SidebarTree.tsx index c5c10cf44..0923b0f3d 100644 --- a/src/ui/sidebar/SidebarTree.tsx +++ b/src/ui/sidebar/SidebarTree.tsx @@ -1309,11 +1309,14 @@ export function SidebarTree({ pin: host.pin ?? false, notes: host.notes, macAddress: host.macAddress, - authType: host.authType, - password: host.password ?? null, - key: host.key ?? null, - keyPassword: host.keyPassword ?? null, - keyType: host.keyType ?? null, + // Key material is never sent to the frontend, so a cloned key-auth + // host would have authType "key" with no key — unusable. Reset to + // password so the clone is in a connectable (editable) state. + authType: host.authType === "key" ? "password" : host.authType, + password: host.authType === "key" ? null : (host.password ?? null), + key: null, + keyPassword: null, + keyType: null, credentialId: host.credentialId ? Number(host.credentialId) : null, overrideCredentialUsername: host.overrideCredentialUsername ?? false, enableSsh: host.enableSsh, diff --git a/src/ui/sidebar/SshToolsPanel.tsx b/src/ui/sidebar/SshToolsPanel.tsx index 688fdad10..3e86c956b 100644 --- a/src/ui/sidebar/SshToolsPanel.tsx +++ b/src/ui/sidebar/SshToolsPanel.tsx @@ -59,6 +59,16 @@ export function SshToolsPanel({ } } + function broadcastArrow(normalSeq: string, appSeq: string) { + for (const tabId of selectedTabIds) { + const tab = terminalTabs.find((t) => t.id === tabId); + const ref = tab?.terminalRef?.current; + if (!ref) continue; + const appMode = ref.getApplicationCursorKeysMode?.() ?? false; + ref.sendInput?.(appMode ? appSeq : normalSeq); + } + } + function handleKeyDown(e: React.KeyboardEvent) { e.preventDefault(); e.stopPropagation(); @@ -86,16 +96,29 @@ export function SshToolsPanel({ } } + if (key === "ArrowUp") { + broadcastArrow("\x1B[A", "\x1BOA"); + return; + } + if (key === "ArrowDown") { + broadcastArrow("\x1B[B", "\x1BOB"); + return; + } + if (key === "ArrowRight") { + broadcastArrow("\x1B[C", "\x1BOC"); + return; + } + if (key === "ArrowLeft") { + broadcastArrow("\x1B[D", "\x1BOD"); + return; + } + const specialMap: Record = { Enter: "\r", Backspace: "\x7F", Delete: "\x1B[3~", Tab: "\t", Escape: "\x1B", - ArrowUp: "\x1B[A", - ArrowDown: "\x1B[B", - ArrowRight: "\x1B[C", - ArrowLeft: "\x1B[D", Home: "\x1B[H", End: "\x1B[F", PageUp: "\x1B[5~", diff --git a/src/ui/ssh/dialogs/SSHAuthDialog.tsx b/src/ui/ssh/dialogs/SSHAuthDialog.tsx index aaa446e59..e284e65a0 100644 --- a/src/ui/ssh/dialogs/SSHAuthDialog.tsx +++ b/src/ui/ssh/dialogs/SSHAuthDialog.tsx @@ -210,7 +210,7 @@ export function SSHAuthDialog({ From c1f124ac80b2a93ca731f7a6995e0d0f6682cda4 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 19 Jun 2026 02:26:41 -0500 Subject: [PATCH 07/17] fix: general qol/small feature additions --- .github/workflows/electron.yml | 54 +- build/after-pack.cjs | 12 + docker/Dockerfile | 2 +- docker/entrypoint.sh | 2 +- docker/nginx-https.conf | 15 + docker/nginx.conf | 15 + electron-builder.json | 1 + electron/main.cjs | 26 + package-lock.json | 37 +- package.json | 5 +- src/backend/dashboard.ts | 3 + src/backend/database/db/index.ts | 9 + src/backend/database/db/schema.ts | 19 + .../database/routes/acme-ssl-routes.ts | 401 +++++++++++++ .../routes/dashboard-service-links-routes.ts | 281 +++++++++ .../database/routes/host-bulk-routes.test.ts | 104 ++++ .../database/routes/host-bulk-routes.ts | 274 +++++++++ .../database/routes/host-network-routes.ts | 10 +- src/backend/database/routes/host.ts | 40 +- .../database/routes/ldap-auth-routes.ts | 9 +- src/backend/database/routes/rbac.ts | 27 +- src/backend/database/routes/snippets.ts | 262 +++++++++ .../database/routes/user-admin-routes.ts | 184 ++++++ .../database/routes/user-oidc-utils.test.ts | 17 + .../database/routes/user-oidc-utils.ts | 22 +- .../routes/user-password-reset-routes.ts | 19 +- .../database/routes/user-preferences.ts | 21 + .../database/routes/user-settings-routes.ts | 105 ++++ src/backend/database/routes/users.ts | 262 ++++++--- src/backend/guacamole/routes.ts | 88 ++- src/backend/guacamole/token-service.ts | 36 +- src/backend/ssh/credential-username.test.ts | 74 ++- src/backend/ssh/credential-username.ts | 34 ++ .../ssh/file-manager-download-routes.ts | 150 ++++- src/backend/ssh/file-manager-session.ts | 1 + src/backend/ssh/file-manager.ts | 4 + src/backend/ssh/host-resolver.ts | 18 +- src/backend/ssh/terminal.ts | 113 ++-- src/backend/utils/ssh-algorithms.ts | 78 ++- src/backend/utils/wake-on-lan.test.ts | 69 ++- src/backend/utils/wake-on-lan.ts | 7 +- src/main.tsx | 19 +- src/types/index.ts | 13 + src/types/ui-types.ts | 16 +- src/ui/AppShell.tsx | 155 ++++- src/ui/api/acme-ssl-api.ts | 47 ++ src/ui/api/dashboard-api.ts | 53 ++ src/ui/api/open-tabs-api.ts | 2 + src/ui/api/settings-api.ts | 40 ++ src/ui/api/snippets-api.ts | 49 ++ src/ui/api/ssh-file-operations-api.ts | 38 +- src/ui/api/ssh-host-management-api.ts | 22 + src/ui/api/user-management-api.ts | 24 + src/ui/auth/Auth.tsx | 52 +- src/ui/auth/ElectronServerConfig.tsx | 124 +++- src/ui/auth/LoginPage.tsx | 47 +- .../proxmox/ProxmoxDiscoverDialog.tsx | 11 +- src/ui/dashboard/DashboardTab.tsx | 326 +++++++++-- src/ui/dashboard/cards/NetworkGraphCard.tsx | 16 +- src/ui/features/file-manager/FileManager.tsx | 97 +++- .../features/file-manager/FileManagerApp.tsx | 7 +- .../file-manager/FileManagerContextMenu.tsx | 24 +- .../file-manager/components/CodeEditor.tsx | 6 +- .../file-manager/components/FileViewer.tsx | 53 ++ .../file-manager/components/FileWindow.tsx | 3 +- .../file-manager/file-manager-types.ts | 2 + src/ui/features/guacamole/GuacamoleApp.tsx | 12 +- .../terminal/MobileTerminalKeyboard.tsx | 394 +++++++++---- src/ui/features/terminal/Terminal.tsx | 165 +++++- .../terminal/terminal-global-styles.ts | 4 - src/ui/hooks/use-status-color-scheme.ts | 51 ++ src/ui/index.css | 32 +- src/ui/lib/terminal-themes.ts | 2 + src/ui/lib/theme.ts | 6 + src/ui/locales/en.json | 187 +++++- src/ui/main-axios.ts | 28 +- src/ui/shell/TabBar.tsx | 64 ++- src/ui/shell/tabUtils.tsx | 30 +- src/ui/sidebar/AdminSettingsPanel.tsx | 133 ++++- src/ui/sidebar/AdminSettingsSections.tsx | 410 +++++++++++++ src/ui/sidebar/AppRail.tsx | 91 ++- src/ui/sidebar/ConnectionsPanel.tsx | 277 +++++++-- src/ui/sidebar/HostEditor.tsx | 108 +++- src/ui/sidebar/HostEditorData.ts | 78 ++- src/ui/sidebar/HostEditorFeatureTabs.tsx | 33 ++ src/ui/sidebar/HostEditorGeneralTab.tsx | 17 + src/ui/sidebar/HostEditorGuacamoleTabs.tsx | 158 ++++++ src/ui/sidebar/HostShareModal.tsx | 3 +- src/ui/sidebar/HostsPanel.tsx | 61 ++ src/ui/sidebar/QuickConnectPanel.tsx | 48 +- src/ui/sidebar/SSOProviderDialog.tsx | 18 + src/ui/sidebar/SidebarTree.tsx | 96 +++- src/ui/sidebar/SnippetsPanel.tsx | 537 +++++++++++++++++- src/ui/sidebar/SplitScreenPanel.tsx | 51 ++ src/ui/sidebar/UserProfilePanel.tsx | 132 +++++ src/ui/user/LanguageSwitcher.tsx | 2 + 96 files changed, 6752 insertions(+), 632 deletions(-) create mode 100644 build/after-pack.cjs create mode 100644 src/backend/database/routes/acme-ssl-routes.ts create mode 100644 src/backend/database/routes/dashboard-service-links-routes.ts create mode 100644 src/backend/database/routes/host-bulk-routes.test.ts create mode 100644 src/ui/api/acme-ssl-api.ts create mode 100644 src/ui/hooks/use-status-color-scheme.ts diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index c6c18963c..d38a945a8 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -734,8 +734,6 @@ jobs: CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}" CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}" RELEASE_DATE="${{ steps.package-version.outputs.release_date }}" - APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}" - APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}" mkdir -p flatpak-submission @@ -762,6 +760,58 @@ jobs: path: flatpak-submission/* retention-days: 30 + - name: Check for Flathub token + id: check_flathub_token + run: | + if [ -n "${{ secrets.FLATHUB_TOKEN }}" ]; then + echo "has_token=true" >> $GITHUB_OUTPUT + fi + + - name: Open PR on Flathub repo + if: steps.check_flathub_token.outputs.has_token == 'true' + env: + GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }} + VERSION: ${{ steps.package-version.outputs.version }} + run: | + FLATHUB_REPO="flathub/com.karmaa.termix" + BRANCH="release-$VERSION" + + git config --global user.name "LukeGus" + git config --global user.email "bugattiguy527@gmail.com" + + git clone "https://x-access-token:${GH_TOKEN}@github.com/${FLATHUB_REPO}.git" flathub-repo + cd flathub-repo + + git checkout -b "$BRANCH" + + cp ../flatpak-submission/com.karmaa.termix.yml ./ + cp ../flatpak-submission/com.karmaa.termix.desktop ./ + cp ../flatpak-submission/com.karmaa.termix.metainfo.xml ./ + cp ../flatpak-submission/flathub.json ./ + cp ../flatpak-submission/com.karmaa.termix.svg ./ + cp ../flatpak-submission/icon-256.png ./ + cp ../flatpak-submission/icon-128.png ./ + + if git diff --quiet && git diff --cached --quiet; then + echo "No changes to submit for $VERSION; Flathub repo already up to date." + exit 0 + fi + + git add -A + git commit -m "Update to $VERSION" + git push origin "$BRANCH" + + EXISTING_PR=$(gh pr list --repo "$FLATHUB_REPO" --head "$BRANCH" --state open --json number -q '.[0].number' || true) + if [ -z "$EXISTING_PR" ]; then + gh pr create --repo "$FLATHUB_REPO" \ + --base master \ + --head "$BRANCH" \ + --title "Update to $VERSION" \ + --body "Automated release update to version $VERSION." + else + echo "PR #$EXISTING_PR already open for $BRANCH." + fi + submit-to-homebrew: runs-on: blacksmith-6vcpu-macos-latest if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos') diff --git a/build/after-pack.cjs b/build/after-pack.cjs new file mode 100644 index 000000000..6796a3cee --- /dev/null +++ b/build/after-pack.cjs @@ -0,0 +1,12 @@ +const fs = require("fs"); +const path = require("path"); + +exports.default = async function afterPack(context) { + const { targets, appOutDir } = context; + + const isDir = targets.some((t) => t.name === "dir"); + if (!isDir) return; + + const markerPath = path.join(appOutDir, ".portable"); + fs.writeFileSync(markerPath, ""); +}; diff --git a/docker/Dockerfile b/docker/Dockerfile index 64c22583f..5b2c28e12 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -60,7 +60,7 @@ ENV DATA_DIR=/app/data \ PORT=8080 \ NODE_ENV=production -RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \ +RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* && \ mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1cd912794..c46e28c24 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -41,7 +41,7 @@ fi mkdir -p /tmp/nginx envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf -mkdir -p /app/data /app/uploads /app/data/.opk +mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true if [ -w /app/data ]; then diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index b98ae8e58..ee58e8fb1 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -71,6 +71,12 @@ http { add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; + location ^~ /.well-known/acme-challenge/ { + root /app/data/acme-webroot; + default_type "text/plain"; + try_files $uri =404; + } + location = /sw.js { root /app/html; expires off; @@ -595,6 +601,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/service-links(/.*)?$ { + proxy_pass http://127.0.0.1:30006; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ^~ /docker/console/ { proxy_pass http://127.0.0.1:30009/; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index 79fd92d0f..5da83bcf5 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -60,6 +60,12 @@ http { add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; + location ^~ /.well-known/acme-challenge/ { + root /app/data/acme-webroot; + default_type "text/plain"; + try_files $uri =404; + } + location = /sw.js { root /app/html; expires off; @@ -596,6 +602,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/service-links(/.*)?$ { + proxy_pass http://127.0.0.1:30006; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ^~ /docker/console/ { proxy_pass http://127.0.0.1:30009/; proxy_http_version 1.1; diff --git a/electron-builder.json b/electron-builder.json index 52739c982..d2eb0685c 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -130,6 +130,7 @@ "artifactName": "termix_macos_${arch}_dmg.${ext}", "sign": true }, + "afterPack": "build/after-pack.cjs", "afterSign": "build/notarize.cjs", "mas": { "provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile", diff --git a/electron/main.cjs b/electron/main.cjs index 5534553e2..75600256b 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -20,6 +20,32 @@ const { URL } = require("url"); const { fork } = require("child_process"); const WebSocket = require("ws"); +// Portable mode: if a `.portable` marker exists next to the executable, +// store all data in a `data` folder beside the exe instead of %APPDATA%. +(function setupPortableDataDir() { + const exeDir = path.dirname(process.execPath); + const markerPath = path.join(exeDir, ".portable"); + const envOverride = process.env.TERMIX_DATA_DIR; + + let portableDataDir = null; + if (envOverride) { + portableDataDir = envOverride; + } else if (app.isPackaged && fs.existsSync(markerPath)) { + portableDataDir = path.join(exeDir, "data"); + } + + if (portableDataDir) { + try { + if (!fs.existsSync(portableDataDir)) { + fs.mkdirSync(portableDataDir, { recursive: true }); + } + app.setPath("userData", portableDataDir); + } catch { + // Fall back to default userData if we can't create the directory. + } + } +})(); + const logFile = path.join(app.getPath("userData"), "termix-main.log"); const electronAuthCookiesPath = path.join( app.getPath("userData"), diff --git a/package-lock.json b/package-lock.json index 76cd40549..4010ad1b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "2.4.0", + "version": "2.4.1", "hasInstallScript": true, "dependencies": { "@types/ldapjs": "^3.0.6", @@ -49,6 +49,9 @@ "@electron/rebuild": "^4.0.4", "@eslint/js": "^9.0.0", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource/fira-code": "^5.2.7", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/source-code-pro": "^5.2.7", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.13", "@radix-ui/react-alert-dialog": "^1.1.16", @@ -2115,6 +2118,36 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/fira-code": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz", + "integrity": "sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/source-code-pro": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/source-code-pro/-/source-code-pro-5.2.7.tgz", + "integrity": "sha512-7papq9TH94KT+S5VSY8cU7tFmwuGkIe3qxXRMscuAXH6AjMU+KJI75f28FzgBVDrlMfA0jjlTV4/x5+H5o/5EQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/package.json b/package.json index f197cc042..f8efb5477 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "2.4.0", + "version": "2.4.1", "description": "Self-hosted SSH and remote desktop management.", "author": "Karmaa", "main": "electron/main.cjs", @@ -81,6 +81,9 @@ "@electron/rebuild": "^4.0.4", "@eslint/js": "^9.0.0", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource/fira-code": "^5.2.7", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/source-code-pro": "^5.2.7", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.13", "@radix-ui/react-alert-dialog": "^1.1.16", diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index b76fab509..707337458 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -13,6 +13,7 @@ import { dashboardLogger } from "./utils/logger.js"; import { SimpleDBOps } from "./utils/simple-db-ops.js"; import { AuthManager } from "./utils/auth-manager.js"; import type { AuthenticatedRequest } from "../types/index.js"; +import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.js"; const app = express(); const authManager = AuthManager.getInstance(); @@ -373,6 +374,8 @@ app.delete("/activity/reset", async (req, res) => { } }); +app.use("/service-links", dashboardServiceLinksRouter); + const PORT = 30006; app.listen(PORT, async () => { try { diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index aaa27e021..a8d694da7 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -694,6 +694,7 @@ const migrateSchema = () => { addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER"); addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER"); addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT"); + addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER"); addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); @@ -753,6 +754,11 @@ const migrateSchema = () => { "enable_file_manager", "INTEGER NOT NULL DEFAULT 1", ); + addColumnIfNotExists( + "ssh_data", + "scp_legacy", + "INTEGER NOT NULL DEFAULT 0", + ); addColumnIfNotExists("ssh_data", "default_path", "TEXT"); addColumnIfNotExists( "ssh_data", @@ -1197,6 +1203,9 @@ const migrateSchema = () => { { column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" }, { column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" }, { column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" }, + { column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" }, + { column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" }, + { column: "wol_broadcast_address", sql: "ALTER TABLE ssh_data ADD COLUMN wol_broadcast_address TEXT" }, ]; for (const migration of sshDataMigrations) { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index a900968e8..e29e3819a 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -127,6 +127,7 @@ export const hosts = sqliteTable("ssh_data", { enableFileManager: integer("enable_file_manager", { mode: "boolean" }) .notNull() .default(true), + scpLegacy: integer("scp_legacy", { mode: "boolean" }).notNull().default(false), enableDocker: integer("enable_docker", { mode: "boolean" }) .notNull() .default(false), @@ -168,12 +169,14 @@ export const hosts = sqliteTable("ssh_data", { vncPort: integer("vnc_port").default(5900), telnetPort: integer("telnet_port").default(23), + rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), rdpUser: text("rdp_user"), rdpPassword: text("rdp_password"), rdpDomain: text("rdp_domain"), rdpSecurity: text("rdp_security"), rdpIgnoreCert: integer("rdp_ignore_cert", { mode: "boolean" }).default(false), + vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), vncPassword: text("vnc_password"), vncUser: text("vnc_user"), @@ -193,6 +196,7 @@ export const hosts = sqliteTable("ssh_data", { socks5ProxyChain: text("socks5_proxy_chain"), macAddress: text("mac_address"), + wolBroadcastAddress: text("wol_broadcast_address"), portKnockSequence: text("port_knock_sequence"), hostKeyFingerprint: text("host_key_fingerprint"), @@ -705,6 +709,8 @@ export const userPreferences = sqliteTable("user_preferences", { disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }), confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }), hiddenRailTabs: text("hidden_rail_tabs"), + compactHostView: integer("compact_host_view", { mode: "boolean" }), + statusColorScheme: text("status_color_scheme"), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -763,6 +769,19 @@ export const hostHealthHistory = sqliteTable("host_health_history", { detail: text("detail"), }); +export const dashboardServiceLinks = sqliteTable("dashboard_service_links", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + label: text("label").notNull(), + url: text("url").notNull(), + order: integer("order").notNull().default(0), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + // --- tmux-monitor begin --- export const tmuxSessionTags = sqliteTable("tmux_session_tags", { id: integer("id").primaryKey({ autoIncrement: true }), diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts new file mode 100644 index 000000000..ecab421be --- /dev/null +++ b/src/backend/database/routes/acme-ssl-routes.ts @@ -0,0 +1,401 @@ +import { execSync } from "child_process"; +import { promises as fs } from "fs"; +import path from "path"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { RequestHandler, Router } from "express"; +import { eq } from "drizzle-orm"; +import { authLogger } from "../../utils/logger.js"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; +import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; + +const DATA_DIR = process.env.DATA_DIR || "./db/data"; +const SSL_DIR = path.join(DATA_DIR, "ssl"); +const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot"); +const CLOUDFLARE_CREDENTIALS_FILE = path.join( + DATA_DIR, + "ssl", + "cloudflare.ini", +); + +export type AcmeSettings = { + enabled: boolean; + domain: string; + email: string; + challengeType: "http-webroot" | "dns-cloudflare"; + cloudflareToken: string; + lastIssuedAt: string | null; + certStatus: "none" | "valid" | "expiring" | "expired"; + certExpiresAt: string | null; +}; + +function getCertInfo(): { + status: "none" | "valid" | "expiring" | "expired"; + expiresAt: string | null; +} { + const certFile = path.join(SSL_DIR, "termix.crt"); + try { + execSync(`openssl x509 -in "${certFile}" -noout 2>/dev/null`, { + stdio: "pipe", + }); + } catch { + return { status: "none", expiresAt: null }; + } + + try { + const endDateRaw = execSync( + `openssl x509 -in "${certFile}" -noout -enddate`, + { stdio: "pipe" }, + ) + .toString() + .trim() + .replace("notAfter=", ""); + const expiresAt = new Date(endDateRaw).toISOString(); + + try { + execSync(`openssl x509 -in "${certFile}" -checkend 0 -noout`, { + stdio: "pipe", + }); + } catch { + return { status: "expired", expiresAt }; + } + + try { + execSync(`openssl x509 -in "${certFile}" -checkend 2592000 -noout`, { + stdio: "pipe", + }); + return { status: "valid", expiresAt }; + } catch { + return { status: "expiring", expiresAt }; + } + } catch { + return { status: "none", expiresAt: null }; + } +} + +function getAcmeSettingsFromDb(): AcmeSettings { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") + .get() as { value: string } | undefined; + + const { status, expiresAt } = getCertInfo(); + const stored = row ? JSON.parse(row.value) : {}; + + return { + enabled: stored.enabled ?? false, + domain: stored.domain ?? "", + email: stored.email ?? "", + challengeType: stored.challengeType ?? "http-webroot", + cloudflareToken: stored.cloudflareToken + ? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}` + : "", + lastIssuedAt: stored.lastIssuedAt ?? null, + certStatus: status, + certExpiresAt: expiresAt, + }; +} + +export function registerAcmeSSLRoutes( + router: Router, + authenticateJWT: RequestHandler, +): void { + /** + * @openapi + * /users/acme-ssl-settings: + * get: + * summary: Get ACME SSL settings + * description: Returns current ACME/Let's Encrypt configuration and certificate status. + * tags: + * - Users + * responses: + * 200: + * description: ACME SSL settings and certificate status. + * 500: + * description: Failed to get ACME SSL settings. + */ + router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => { + try { + res.json(getAcmeSettingsFromDb()); + } catch (err) { + authLogger.error("Failed to get ACME SSL settings", err); + res.status(500).json({ error: "Failed to get ACME SSL settings" }); + } + }); + + /** + * @openapi + * /users/acme-ssl-settings: + * patch: + * summary: Update ACME SSL settings (admin only) + * description: Saves ACME/Let's Encrypt configuration. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * domain: + * type: string + * email: + * type: string + * challengeType: + * type: string + * enum: [http-webroot, dns-cloudflare] + * cloudflareToken: + * type: string + * responses: + * 200: + * description: ACME SSL settings updated. + * 403: + * description: Not authorized. + * 500: + * description: Failed to update ACME SSL settings. + */ + router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + + const existing = db.$client + .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") + .get() as { value: string } | undefined; + const current = existing ? JSON.parse(existing.value) : {}; + + const { enabled, domain, email, challengeType, cloudflareToken } = + req.body; + + const updated = { + ...current, + ...(typeof enabled === "boolean" && { enabled }), + ...(typeof domain === "string" && { domain }), + ...(typeof email === "string" && { email }), + ...(typeof challengeType === "string" && { challengeType }), + ...(typeof cloudflareToken === "string" && + cloudflareToken && + !cloudflareToken.includes("*") && { cloudflareToken }), + }; + + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)", + ) + .run(JSON.stringify(updated)); + + const { ipAddress, userAgent } = getRequestMeta(req); + const actorRecord = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + await logAudit({ + userId, + username: actorRecord[0]?.username ?? userId, + action: "update_acme_ssl_settings", + resourceType: "setting", + details: JSON.stringify({ + enabled, + domain, + email, + challengeType, + hasCloudflareToken: !!updated.cloudflareToken, + }), + ipAddress, + userAgent, + success: true, + }); + + res.json(getAcmeSettingsFromDb()); + } catch (err) { + authLogger.error("Failed to update ACME SSL settings", err); + res.status(500).json({ error: "Failed to update ACME SSL settings" }); + } + }); + + /** + * @openapi + * /users/acme-ssl-request: + * post: + * summary: Request or renew Let's Encrypt certificate (admin only) + * description: Triggers certbot to issue or renew a certificate using the configured challenge method. + * tags: + * - Users + * responses: + * 200: + * description: Certificate issued or renewed successfully. + * 400: + * description: Invalid configuration. + * 403: + * description: Not authorized. + * 500: + * description: Certificate issuance failed. + */ + router.post("/acme-ssl-request", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") + .get() as { value: string } | undefined; + + if (!row) { + return res.status(400).json({ error: "ACME settings not configured" }); + } + + const settings = JSON.parse(row.value); + const { domain, email, challengeType, cloudflareToken } = settings; + + if (!domain || !email) { + return res.status(400).json({ error: "Domain and email are required" }); + } + + try { + execSync("certbot --version", { stdio: "pipe" }); + } catch { + return res + .status(500) + .json({ error: "certbot is not available in this environment" }); + } + + await fs.mkdir(SSL_DIR, { recursive: true }); + await fs.mkdir(ACME_WEBROOT, { recursive: true }); + + let certbotCmd: string; + + if (challengeType === "dns-cloudflare") { + if (!cloudflareToken) { + return res + .status(400) + .json({ + error: "Cloudflare API token is required for DNS challenge", + }); + } + + await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), { + recursive: true, + }); + await fs.writeFile( + CLOUDFLARE_CREDENTIALS_FILE, + `dns_cloudflare_api_token = ${cloudflareToken}\n`, + { mode: 0o600 }, + ); + + certbotCmd = [ + "certbot", + "certonly", + "--non-interactive", + "--agree-tos", + "--dns-cloudflare", + `--dns-cloudflare-credentials "${CLOUDFLARE_CREDENTIALS_FILE}"`, + "--dns-cloudflare-propagation-seconds", + "30", + "-d", + `"${domain}"`, + "--email", + `"${email}"`, + "--cert-name", + "termix", + ].join(" "); + } else { + certbotCmd = [ + "certbot", + "certonly", + "--non-interactive", + "--agree-tos", + "--webroot", + "-w", + `"${ACME_WEBROOT}"`, + "-d", + `"${domain}"`, + "--email", + `"${email}"`, + "--cert-name", + "termix", + ].join(" "); + } + + authLogger.info("Requesting Let's Encrypt certificate", { + domain, + challengeType, + operation: "acme_cert_request", + }); + + execSync(certbotCmd, { stdio: "pipe", timeout: 120000 }); + + const liveDir = `/etc/letsencrypt/live/termix`; + const fullchainSrc = path.join(liveDir, "fullchain.pem"); + const privkeySrc = path.join(liveDir, "privkey.pem"); + const certDest = path.join(SSL_DIR, "termix.crt"); + const keyDest = path.join(SSL_DIR, "termix.key"); + + await fs.copyFile(fullchainSrc, certDest); + await fs.copyFile(privkeySrc, keyDest); + await fs.chmod(keyDest, 0o600); + await fs.chmod(certDest, 0o644); + + const updated = { ...settings, lastIssuedAt: new Date().toISOString() }; + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)", + ) + .run(JSON.stringify(updated)); + + authLogger.info("Let's Encrypt certificate issued and installed", { + domain, + operation: "acme_cert_installed", + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + const actorRecord = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + await logAudit({ + userId, + username: actorRecord[0]?.username ?? userId, + action: "acme_ssl_request", + resourceType: "setting", + details: JSON.stringify({ domain, challengeType, success: true }), + ipAddress, + userAgent, + success: true, + }); + + res.json({ success: true, ...getAcmeSettingsFromDb() }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + authLogger.error("ACME certificate request failed", err); + + const { ipAddress, userAgent } = getRequestMeta(req); + const actorRecord = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + await logAudit({ + userId, + username: actorRecord[0]?.username ?? userId, + action: "acme_ssl_request", + resourceType: "setting", + details: JSON.stringify({ error: message }), + ipAddress, + userAgent, + success: false, + }); + + res.status(500).json({ error: `Certificate request failed: ${message}` }); + } + }); +} diff --git a/src/backend/database/routes/dashboard-service-links-routes.ts b/src/backend/database/routes/dashboard-service-links-routes.ts new file mode 100644 index 000000000..e7dacebec --- /dev/null +++ b/src/backend/database/routes/dashboard-service-links-routes.ts @@ -0,0 +1,281 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { Request, Response } from "express"; +import { and, asc, eq } from "drizzle-orm"; +import { dashboardLogger } from "../../utils/logger.js"; +import { db } from "../db/index.js"; +import { dashboardServiceLinks } from "../db/schema.js"; +import { isNonEmptyString } from "./host-normalizers.js"; +import express from "express"; + +export const dashboardServiceLinksRouter = express.Router(); + +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +/** + * @openapi + * /service-links: + * get: + * summary: Get service links + * description: Returns all dashboard service links for the authenticated user. + * tags: + * - Dashboard + * responses: + * 200: + * description: List of service links. + * 500: + * description: Failed to fetch service links. + */ +dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const links = await db + .select() + .from(dashboardServiceLinks) + .where(eq(dashboardServiceLinks.userId, userId)) + .orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id)); + res.json(links); + } catch (err) { + dashboardLogger.error("Failed to fetch service links", err); + res.status(500).json({ error: "Failed to fetch service links" }); + } +}); + +/** + * @openapi + * /service-links: + * post: + * summary: Create service link + * description: Creates a new dashboard service link for the authenticated user. + * tags: + * - Dashboard + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - label + * - url + * properties: + * label: + * type: string + * url: + * type: string + * responses: + * 201: + * description: Service link created. + * 400: + * description: Invalid data. + * 500: + * description: Failed to create service link. + */ +dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { label, url } = req.body; + + if (!isNonEmptyString(label) || !isNonEmptyString(url)) { + return res.status(400).json({ error: "label and url are required" }); + } + if (!isValidUrl(url)) { + return res + .status(400) + .json({ error: "url must be a valid http or https URL" }); + } + + try { + const existing = await db + .select({ order: dashboardServiceLinks.order }) + .from(dashboardServiceLinks) + .where(eq(dashboardServiceLinks.userId, userId)) + .orderBy(asc(dashboardServiceLinks.order)); + + const nextOrder = + existing.length > 0 ? existing[existing.length - 1].order + 1 : 0; + + const [created] = await db + .insert(dashboardServiceLinks) + .values({ + userId, + label: label.trim(), + url: url.trim(), + order: nextOrder, + createdAt: new Date().toISOString(), + }) + .returning(); + + res.status(201).json(created); + } catch (err) { + dashboardLogger.error("Failed to create service link", err); + res.status(500).json({ error: "Failed to create service link" }); + } +}); + +/** + * @openapi + * /service-links/{id}: + * delete: + * summary: Delete service link + * description: Deletes a dashboard service link by ID. + * tags: + * - Dashboard + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Service link deleted. + * 400: + * description: Invalid id. + * 404: + * description: Not found. + * 500: + * description: Failed to delete service link. + */ +dashboardServiceLinksRouter.delete( + "/:id", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const idParam = Array.isArray(req.params.id) + ? req.params.id[0] + : req.params.id; + const id = parseInt(idParam); + + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid id" }); + } + + try { + const existing = await db + .select() + .from(dashboardServiceLinks) + .where( + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ); + + if (existing.length === 0) { + return res.status(404).json({ error: "Not found" }); + } + + await db + .delete(dashboardServiceLinks) + .where( + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ); + + res.json({ message: "Service link deleted" }); + } catch (err) { + dashboardLogger.error("Failed to delete service link", err); + res.status(500).json({ error: "Failed to delete service link" }); + } + }, +); + +/** + * @openapi + * /service-links/{id}: + * put: + * summary: Update service link + * description: Updates label or url of a dashboard service link. + * tags: + * - Dashboard + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * label: + * type: string + * url: + * type: string + * responses: + * 200: + * description: Service link updated. + * 400: + * description: Invalid data. + * 404: + * description: Not found. + * 500: + * description: Failed to update service link. + */ +dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const idParam = Array.isArray(req.params.id) + ? req.params.id[0] + : req.params.id; + const id = parseInt(idParam); + const { label, url } = req.body; + + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid id" }); + } + if (url !== undefined && !isValidUrl(url)) { + return res + .status(400) + .json({ error: "url must be a valid http or https URL" }); + } + + try { + const existing = await db + .select() + .from(dashboardServiceLinks) + .where( + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ); + + if (existing.length === 0) { + return res.status(404).json({ error: "Not found" }); + } + + const updates: Partial<{ label: string; url: string }> = {}; + if (isNonEmptyString(label)) updates.label = label.trim(); + if (isNonEmptyString(url)) updates.url = url.trim(); + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ error: "Nothing to update" }); + } + + const [updated] = await db + .update(dashboardServiceLinks) + .set(updates) + .where( + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ) + .returning(); + + res.json(updated); + } catch (err) { + dashboardLogger.error("Failed to update service link", err); + res.status(500).json({ error: "Failed to update service link" }); + } +}); diff --git a/src/backend/database/routes/host-bulk-routes.test.ts b/src/backend/database/routes/host-bulk-routes.test.ts new file mode 100644 index 000000000..d105df9ed --- /dev/null +++ b/src/backend/database/routes/host-bulk-routes.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { parseSSHConfig } from "./host-bulk-routes.js"; + +describe("parseSSHConfig", () => { + it("parses a basic Host block", () => { + const config = ` +Host myserver + HostName 192.168.1.10 + User alice + Port 2222 +`; + const result = parseSSHConfig(config); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: "myserver", + hostname: "192.168.1.10", + user: "alice", + port: 2222, + }); + }); + + it("parses multiple Host blocks", () => { + const config = ` +Host web + HostName web.example.com + User deploy + +Host db + HostName db.example.com + User postgres + Port 5432 +`; + const result = parseSSHConfig(config); + expect(result).toHaveLength(2); + expect(result[0].name).toBe("web"); + expect(result[1].name).toBe("db"); + expect(result[1].port).toBe(5432); + }); + + it("ignores comment lines", () => { + const config = ` +# This is a comment +Host server + # Another comment + HostName 10.0.0.1 + User root +`; + const result = parseSSHConfig(config); + expect(result).toHaveLength(1); + expect(result[0].hostname).toBe("10.0.0.1"); + }); + + it("skips wildcard Host entries", () => { + const config = ` +Host * + ServerAliveInterval 60 + +Host prod + HostName prod.example.com + User ubuntu +`; + const result = parseSSHConfig(config); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("prod"); + }); + + it("captures IdentityFile and ProxyJump", () => { + const config = ` +Host bastion + HostName bastion.example.com + User ec2-user + IdentityFile ~/.ssh/id_rsa + ProxyJump jumphost.example.com +`; + const result = parseSSHConfig(config); + expect(result).toHaveLength(1); + expect(result[0].identityFile).toBe("~/.ssh/id_rsa"); + expect(result[0].proxyJump).toBe("jumphost.example.com"); + }); + + it("skips Host blocks without a HostName", () => { + const config = ` +Host alias-only + User foo +`; + const result = parseSSHConfig(config); + expect(result).toHaveLength(0); + }); + + it("defaults port to undefined when not specified", () => { + const config = ` +Host server + HostName 1.2.3.4 + User root +`; + const result = parseSSHConfig(config); + expect(result[0].port).toBeUndefined(); + }); + + it("returns empty array for empty input", () => { + expect(parseSSHConfig("")).toHaveLength(0); + expect(parseSSHConfig(" \n\n ")).toHaveLength(0); + }); +}); diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 3f72fb516..74c4b2f82 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -11,6 +11,68 @@ import { normalizeImportedHost, } from "./host-normalizers.js"; +type SSHConfigHost = { + name: string; + hostname?: string; + user?: string; + port?: number; + identityFile?: string; + proxyJump?: string; +}; + +export function parseSSHConfig(content: string): SSHConfigHost[] { + const results: SSHConfigHost[] = []; + let current: SSHConfigHost | null = null; + + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const spaceIdx = line.indexOf(" "); + if (spaceIdx === -1) continue; + + const key = line.slice(0, spaceIdx).toLowerCase(); + const value = line.slice(spaceIdx + 1).trim(); + + if (key === "host") { + if (current && current.hostname) results.push(current); + // Skip wildcard patterns + if (value === "*" || value.includes("*") || value.includes("?")) { + current = null; + } else { + current = { name: value }; + } + continue; + } + + if (!current) continue; + + switch (key) { + case "hostname": + current.hostname = value; + break; + case "user": + current.user = value; + break; + case "port": { + const p = Number.parseInt(value, 10); + if (p > 0 && p <= 65535) current.port = p; + break; + } + case "identityfile": + if (!current.identityFile) current.identityFile = value; + break; + case "proxyjump": + current.proxyJump = value; + break; + } + } + + if (current && current.hostname) results.push(current); + + return results; +} + export function registerHostBulkRoutes( router: Router, authenticateJWT: RequestHandler, @@ -525,4 +587,216 @@ export function registerHostBulkRoutes( }); }, ); + + /** + * @openapi + * /host/ssh-config-import: + * post: + * summary: Import hosts from an OpenSSH config file + * description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - content + * properties: + * content: + * type: string + * description: Raw text content of the SSH config file. + * overwrite: + * type: boolean + * responses: + * 200: + * description: Import completed. + * 400: + * description: Invalid request body. + */ + router.post( + "/ssh-config-import", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { content, overwrite } = req.body; + + if (!isNonEmptyString(content)) { + return res + .status(400) + .json({ + error: "content is required and must be a non-empty string", + }); + } + + let parsed: SSHConfigHost[]; + try { + parsed = parseSSHConfig(content); + } catch (err) { + return res + .status(400) + .json({ error: "Failed to parse SSH config file" }); + } + + if (parsed.length === 0) { + return res + .status(400) + .json({ + error: "No valid Host entries found in the SSH config file", + }); + } + + if (parsed.length > 100) { + return res + .status(400) + .json({ error: "Maximum 100 hosts allowed per import" }); + } + + const hostsToImport = parsed.map((h) => ({ + name: h.name, + ip: h.hostname, + port: h.port ?? 22, + username: h.user, + authType: h.identityFile ? "key" : undefined, + connectionType: "ssh", + enableSsh: true, + ...(h.proxyJump + ? { + jumpHosts: [{ host: h.proxyJump, port: 22 }], + } + : {}), + })); + + const results = { + success: 0, + updated: 0, + skipped: 0, + failed: 0, + errors: [] as string[], + }; + + let existingHostMap: Map | undefined; + if (overwrite) { + try { + const allHosts = await SimpleDBOps.select>( + db.select().from(hosts).where(eq(hosts.userId, userId)), + "ssh_data", + userId, + ); + existingHostMap = new Map(); + for (const h of allHosts) { + const key = `${h.ip}:${h.port}:${h.username}`; + existingHostMap.set(key, { id: h.id as number }); + } + } catch { + existingHostMap = undefined; + } + } + + for (let i = 0; i < hostsToImport.length; i++) { + const hostData = normalizeImportedHost( + hostsToImport[i] as Record, + ); + + try { + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { + results.failed++; + results.errors.push( + `Host "${parsed[i].name}": Missing required fields (HostName, Port)`, + ); + continue; + } + + const sshDataObj: Record = { + userId, + connectionType: "ssh", + name: hostData.name || hostData.ip, + folder: "Default", + tags: "", + ip: hostData.ip, + port: hostData.port, + username: hostData.username || null, + authType: hostData.authType || "none", + password: null, + key: null, + keyPassword: null, + keyType: null, + credentialId: null, + pin: false, + enableTerminal: true, + enableTunnel: true, + enableFileManager: true, + enableDocker: false, + enableProxmox: false, + enableTmuxMonitor: false, + showTerminalInSidebar: 0, + showFileManagerInSidebar: 0, + showTunnelInSidebar: 0, + showDockerInSidebar: 0, + showServerStatsInSidebar: 0, + defaultPath: "/", + sudoPassword: null, + tunnelConnections: "[]", + jumpHosts: hostData.jumpHosts + ? JSON.stringify(hostData.jumpHosts) + : null, + quickActions: null, + statsConfig: null, + dockerConfig: null, + proxmoxConfig: null, + terminalConfig: null, + forceKeyboardInteractive: "false", + notes: null, + useSocks5: 0, + socks5Host: null, + socks5Port: null, + socks5Username: null, + socks5Password: null, + socks5ProxyChain: null, + portKnockSequence: null, + overrideCredentialUsername: 0, + enableSsh: true, + enableRdp: false, + enableVnc: false, + enableTelnet: false, + updatedAt: new Date().toISOString(), + }; + + const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; + const existing = existingHostMap?.get(lookupKey); + + if (existing) { + await SimpleDBOps.update( + hosts, + "ssh_data", + eq(hosts.id, existing.id), + sshDataObj, + userId, + ); + results.updated++; + } else { + sshDataObj.createdAt = new Date().toISOString(); + await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId); + results.success++; + } + } catch (error) { + results.failed++; + results.errors.push( + `Host "${parsed[i].name}": ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + res.json({ + message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, + success: results.success, + updated: results.updated, + skipped: results.skipped, + failed: results.failed, + errors: results.errors, + }); + }, + ); } diff --git a/src/backend/database/routes/host-network-routes.ts b/src/backend/database/routes/host-network-routes.ts index 1d98591e9..654aa7c66 100644 --- a/src/backend/database/routes/host-network-routes.ts +++ b/src/backend/database/routes/host-network-routes.ts @@ -101,7 +101,10 @@ export function registerHostNetworkRoutes( try { const host = await db - .select({ macAddress: hosts.macAddress }) + .select({ + macAddress: hosts.macAddress, + wolBroadcastAddress: hosts.wolBroadcastAddress, + }) .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) .then((rows) => rows[0]); @@ -116,7 +119,10 @@ export function registerHostNetworkRoutes( .json({ error: "No valid MAC address configured" }); } - await sendWakeOnLan(host.macAddress); + await sendWakeOnLan( + host.macAddress, + host.wolBroadcastAddress ?? undefined, + ); sshLogger.info("Wake-on-LAN packet sent", { operation: "wake_on_lan", diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 6e0d18230..45c35cd49 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -153,6 +153,7 @@ router.post( enableTerminal, enableTunnel, enableFileManager, + scpLegacy, enableDocker, enableProxmox, enableTmuxMonitor, @@ -184,6 +185,7 @@ router.post( portKnockSequence, overrideCredentialUsername, macAddress, + wolBroadcastAddress, enableSsh, enableRdp, enableVnc, @@ -256,6 +258,7 @@ router.post( ? JSON.stringify(quickActions) : null, enableFileManager: enableFileManager ? 1 : 0, + scpLegacy: scpLegacy ? 1 : 0, enableDocker: enableDocker ? 1 : 0, enableProxmox: enableProxmox ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, @@ -301,6 +304,7 @@ router.post( ? JSON.stringify(socks5ProxyChain) : null, macAddress: macAddress || null, + wolBroadcastAddress: wolBroadcastAddress || null, portKnockSequence: portKnockSequence ? JSON.stringify(portKnockSequence) : null, @@ -722,6 +726,7 @@ router.put( enableTerminal, enableTunnel, enableFileManager, + scpLegacy, enableDocker, enableProxmox, enableTmuxMonitor, @@ -753,6 +758,7 @@ router.put( portKnockSequence, overrideCredentialUsername, macAddress, + wolBroadcastAddress, enableSsh, enableRdp, enableVnc, @@ -822,6 +828,7 @@ router.put( ? JSON.stringify(quickActions) : null, enableFileManager: enableFileManager ? 1 : 0, + scpLegacy: scpLegacy ? 1 : 0, enableDocker: enableDocker ? 1 : 0, enableProxmox: enableProxmox ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, @@ -867,6 +874,7 @@ router.put( ? JSON.stringify(socks5ProxyChain) : null, macAddress: macAddress || null, + wolBroadcastAddress: wolBroadcastAddress || null, portKnockSequence: portKnockSequence ? JSON.stringify(portKnockSequence) : null, @@ -987,6 +995,8 @@ router.put( .select({ userId: hosts.userId, credentialId: hosts.credentialId, + rdpCredentialId: hosts.rdpCredentialId, + vncCredentialId: hosts.vncCredentialId, authType: hosts.authType, }) .from(hosts) @@ -1024,11 +1034,26 @@ router.put( }); } - if (sshDataObj.credentialId !== undefined) { - if ( - hostRecord[0].credentialId !== null && - sshDataObj.credentialId === null - ) { + { + const newCredId = + sshDataObj.credentialId !== undefined + ? sshDataObj.credentialId + : hostRecord[0].credentialId; + const newRdpCredId = + sshDataObj.rdpCredentialId !== undefined + ? sshDataObj.rdpCredentialId + : hostRecord[0].rdpCredentialId; + const newVncCredId = + sshDataObj.vncCredentialId !== undefined + ? sshDataObj.vncCredentialId + : hostRecord[0].vncCredentialId; + const hadCredential = + hostRecord[0].credentialId !== null || + hostRecord[0].rdpCredentialId !== null || + hostRecord[0].vncCredentialId !== null; + const willHaveCredential = + newCredId !== null || newRdpCredId !== null || newVncCredId !== null; + if (hadCredential && !willHaveCredential) { await db .delete(hostAccess) .where(eq(hostAccess.hostId, Number(hostId))); @@ -1168,6 +1193,7 @@ router.get( tunnelConnections: hosts.tunnelConnections, jumpHosts: hosts.jumpHosts, enableFileManager: hosts.enableFileManager, + scpLegacy: hosts.scpLegacy, defaultPath: hosts.defaultPath, autostartPassword: hosts.autostartPassword, autostartKey: hosts.autostartKey, @@ -1202,6 +1228,7 @@ router.get( ignoreCert: hosts.ignoreCert, guacamoleConfig: hosts.guacamoleConfig, macAddress: hosts.macAddress, + wolBroadcastAddress: hosts.wolBroadcastAddress, dockerConfig: hosts.dockerConfig, proxmoxConfig: hosts.proxmoxConfig, enableSsh: hosts.enableSsh, @@ -1212,11 +1239,13 @@ router.get( rdpPort: hosts.rdpPort, vncPort: hosts.vncPort, telnetPort: hosts.telnetPort, + rdpCredentialId: hosts.rdpCredentialId, rdpUser: hosts.rdpUser, rdpPassword: hosts.rdpPassword, rdpDomain: hosts.rdpDomain, rdpSecurity: hosts.rdpSecurity, rdpIgnoreCert: hosts.rdpIgnoreCert, + vncCredentialId: hosts.vncCredentialId, vncUser: hosts.vncUser, vncPassword: hosts.vncPassword, telnetUser: hosts.telnetUser, @@ -1586,6 +1615,7 @@ router.get( enableTerminal: !!resolvedHost.enableTerminal, enableTunnel: !!resolvedHost.enableTunnel, enableFileManager: resolvedHost.enableFileManager !== false, + scpLegacy: !!resolvedHost.scpLegacy, enableDocker: !!resolvedHost.enableDocker, enableProxmox: !!resolvedHost.enableProxmox, enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor, diff --git a/src/backend/database/routes/ldap-auth-routes.ts b/src/backend/database/routes/ldap-auth-routes.ts index c4d347060..cefdc6cb3 100644 --- a/src/backend/database/routes/ldap-auth-routes.ts +++ b/src/backend/database/routes/ldap-auth-routes.ts @@ -298,14 +298,7 @@ export function registerLDAPAuthRoutes(router: Router): void { const isFirst = (countRow?.count || 0) === 0; if (!isFirst && !autoProvision) { - const regRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'allow_registration'", - ) - .get() as { value: string } | undefined; - if (regRow && regRow.value !== "true") { - return res.status(403).json({ error: "Registration is disabled" }); - } + return res.status(403).json({ error: "Registration is disabled" }); } userId = nanoid(); diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 97227da1d..5582582f9 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -129,7 +129,12 @@ router.post( return res.status(403).json({ error: "Not host owner" }); } - if (!host[0].credentialId && host[0].authType !== "opkssh") { + if ( + !host[0].credentialId && + !host[0].rdpCredentialId && + !host[0].vncCredentialId && + host[0].authType !== "opkssh" + ) { return res.status(400).json({ error: "Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.", @@ -204,21 +209,25 @@ router.post( .delete(sharedCredentials) .where(eq(sharedCredentials.hostAccessId, existing[0].id)); - if (host[0].credentialId) { + const activeCredentialId = + host[0].credentialId ?? + host[0].rdpCredentialId ?? + host[0].vncCredentialId; + if (activeCredentialId) { const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); if (targetType === "user") { await sharedCredManager.createSharedCredentialForUser( existing[0].id, - host[0].credentialId, + activeCredentialId, targetUserId!, userId, ); } else { await sharedCredManager.createSharedCredentialsForRole( existing[0].id, - host[0].credentialId, + activeCredentialId, targetRoleId!, userId, ); @@ -252,18 +261,22 @@ router.post( await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); - if (host[0].credentialId) { + const activeCredentialId = + host[0].credentialId ?? + host[0].rdpCredentialId ?? + host[0].vncCredentialId; + if (activeCredentialId) { if (targetType === "user") { await sharedCredManager.createSharedCredentialForUser( result.lastInsertRowid as number, - host[0].credentialId, + activeCredentialId, targetUserId!, userId, ); } else { await sharedCredManager.createSharedCredentialsForRole( result.lastInsertRowid as number, - host[0].credentialId, + activeCredentialId, targetRoleId!, userId, ); diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 4a515115c..5848b20e0 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -924,6 +924,268 @@ router.post( }, ); +/** + * @openapi + * /snippets/export: + * get: + * summary: Export all snippets and folders as JSON + * description: Returns all snippets and snippet folders for the authenticated user as a JSON export. + * tags: + * - Snippets + * responses: + * 200: + * description: Export object containing snippets and folders arrays. + * 400: + * description: Invalid userId. + * 500: + * description: Failed to export snippets. + */ +router.get( + "/export", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId for snippet export"); + return res.status(400).json({ error: "Invalid userId" }); + } + + try { + const allSnippets = await db + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + .orderBy(asc(snippets.folder), asc(snippets.order)); + + const allFolders = await db + .select() + .from(snippetFolders) + .where(eq(snippetFolders.userId, userId)) + .orderBy(asc(snippetFolders.name)); + + const exportedSnippets = allSnippets.map((s) => ({ + name: s.name, + content: s.content, + description: s.description, + folder: s.folder, + order: s.order, + hostFilter: s.hostFilter, + })); + + const exportedFolders = allFolders.map((f) => ({ + name: f.name, + color: f.color, + icon: f.icon, + })); + + authLogger.success(`Snippets exported by user ${userId}`, { + operation: "snippet_export", + userId, + snippetCount: exportedSnippets.length, + folderCount: exportedFolders.length, + }); + + res.json({ snippets: exportedSnippets, folders: exportedFolders }); + } catch (err) { + authLogger.error("Failed to export snippets", err); + res.status(500).json({ error: "Failed to export snippets" }); + } + }, +); + +/** + * @openapi + * /snippets/bulk-import: + * post: + * summary: Bulk import snippets and folders from JSON + * description: Imports snippets and folders. Existing folders are skipped; existing snippets (matched by name+folder) can be skipped or overwritten. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * snippets: + * type: array + * folders: + * type: array + * overwrite: + * type: boolean + * responses: + * 200: + * description: Import results with counts. + * 400: + * description: Invalid request body. + * 500: + * description: Failed to import snippets. + */ +router.post( + "/bulk-import", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { + snippets: snippetsToImport, + folders: foldersToImport, + overwrite, + } = req.body; + + if (!isNonEmptyString(userId)) { + return res.status(400).json({ error: "Invalid userId" }); + } + + if (!Array.isArray(snippetsToImport) && !Array.isArray(foldersToImport)) { + return res + .status(400) + .json({ error: "snippets or folders array is required" }); + } + + const results = { + snippetsImported: 0, + snippetsSkipped: 0, + snippetsUpdated: 0, + foldersImported: 0, + foldersSkipped: 0, + failed: 0, + errors: [] as string[], + }; + + try { + if (Array.isArray(foldersToImport)) { + for (const folder of foldersToImport) { + if (!isNonEmptyString(folder.name)) { + results.failed++; + results.errors.push(`Folder missing name`); + continue; + } + + const existing = await db + .select() + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, folder.name.trim()), + ), + ) + .limit(1); + + if (existing.length > 0) { + results.foldersSkipped++; + continue; + } + + await db.insert(snippetFolders).values({ + userId, + name: folder.name.trim(), + color: folder.color?.trim() || null, + icon: folder.icon?.trim() || null, + }); + results.foldersImported++; + } + } + + if (Array.isArray(snippetsToImport)) { + for (let i = 0; i < snippetsToImport.length; i++) { + const s = snippetsToImport[i]; + + if (!isNonEmptyString(s.name) || !isNonEmptyString(s.content)) { + results.failed++; + results.errors.push( + `Snippet ${i + 1}: name and content are required`, + ); + continue; + } + + const folderVal = s.folder?.trim() || null; + + const existing = await db + .select() + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + eq(snippets.name, s.name.trim()), + folderVal + ? eq(snippets.folder, folderVal) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ) + .limit(1); + + if (existing.length > 0) { + if (!overwrite) { + results.snippetsSkipped++; + continue; + } + + await db + .update(snippets) + .set({ + content: s.content.trim(), + description: s.description?.trim() || null, + folder: folderVal, + order: + typeof s.order === "number" ? s.order : existing[0].order, + hostFilter: s.hostFilter || null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + and( + eq(snippets.id, existing[0].id), + eq(snippets.userId, userId), + ), + ); + results.snippetsUpdated++; + continue; + } + + const maxOrderResult = await db + .select({ maxOrder: sql`MAX(${snippets.order})` }) + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + folderVal + ? eq(snippets.folder, folderVal) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ); + const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; + + await db.insert(snippets).values({ + userId, + name: s.name.trim(), + content: s.content.trim(), + description: s.description?.trim() || null, + folder: folderVal, + order: typeof s.order === "number" ? s.order : maxOrder + 1, + hostFilter: s.hostFilter || null, + }); + results.snippetsImported++; + } + } + + authLogger.success(`Snippets bulk-imported by user ${userId}`, { + operation: "snippet_bulk_import", + userId, + ...results, + }); + + res.json({ success: true, ...results }); + } catch (err) { + authLogger.error("Failed to bulk import snippets", err); + res.status(500).json({ error: "Failed to import snippets" }); + } + }, +); + /** * @openapi * /snippets: diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index d5fded975..f5b9af342 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -5,6 +5,9 @@ import { authLogger } from "../../utils/logger.js"; import { db } from "../db/index.js"; import { users, roles, userRoles } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import bcrypt from "bcryptjs"; +import { nanoid } from "nanoid"; +import { AuthManager } from "../../utils/auth-manager.js"; function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; @@ -413,4 +416,185 @@ export function registerUserAdminRoutes( res.status(500).json({ error: "Failed to remove admin status" }); } }); + + /** + * @openapi + * /users/admin-create: + * post: + * summary: Admin create user + * description: Allows an admin to create a new user regardless of whether public registration is enabled. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * password: + * type: string + * responses: + * 200: + * description: User created successfully. + * 400: + * description: Username and password are required. + * 403: + * description: Not authorized. + * 409: + * description: Username already exists. + * 500: + * description: Failed to create user. + */ + router.post("/admin-create", authenticateJWT, async (req, res) => { + const adminId = (req as AuthenticatedRequest).userId; + + try { + const adminUser = await db + .select() + .from(users) + .where(eq(users.id, adminId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + } catch (err) { + authLogger.error("Failed to verify admin status", err); + return res.status(500).json({ error: "Failed to verify admin status" }); + } + + const { username, password } = req.body; + + if (!isNonEmptyString(username) || !isNonEmptyString(password)) { + return res + .status(400) + .json({ error: "Username and password are required" }); + } + + try { + const existing = await db + .select() + .from(users) + .where(eq(users.username, username)); + if (existing && existing.length > 0) { + return res.status(409).json({ error: "Username already exists" }); + } + + const saltRounds = parseInt(process.env.SALT || "10", 10); + const password_hash = await bcrypt.hash(password, saltRounds); + const id = nanoid(); + + db.$client.transaction(() => { + db.$client + .prepare( + "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + id, + username, + password_hash, + 0, + 0, + "", + "", + "", + "", + "", + "", + "", + "openid email profile", + null, + 0, + null, + ); + })(); + + try { + const userRole = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, "user")) + .limit(1); + if (userRole.length > 0) { + await db.insert(userRoles).values({ + userId: id, + roleId: userRole[0].id, + grantedBy: adminId, + }); + } + } catch (roleError) { + authLogger.error( + "Failed to assign default role during admin create", + roleError, + { + operation: "admin_create_user_role", + userId: id, + }, + ); + } + + const authManager = AuthManager.getInstance(); + try { + await authManager.registerUser(id, password); + } catch (encryptionError) { + await db.delete(users).where(eq(users.id, id)); + authLogger.error( + "Failed to setup user encryption during admin create, rolled back", + encryptionError, + { operation: "admin_create_user_encryption_failed", userId: id }, + ); + return res.status(500).json({ + error: "Failed to setup user security - user creation cancelled", + }); + } + + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error( + "Failed to persist admin-created user to disk", + saveError, + { + operation: "admin_create_user_save_failed", + userId: id, + }, + ); + } + + authLogger.success("User created by admin", { + operation: "admin_create_user_success", + adminId, + userId: id, + username, + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + const adminRecord = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, adminId)) + .limit(1); + await logAudit({ + userId: adminId, + username: adminRecord[0]?.username ?? adminId, + action: "create_user", + resourceType: "user", + resourceId: id, + resourceName: username, + ipAddress, + userAgent, + success: true, + }); + + res.json({ + message: "User created", + toast: { type: "success", message: `User created: ${username}` }, + }); + } catch (err) { + authLogger.error("Failed to admin-create user", err); + res.status(500).json({ error: "Failed to create user" }); + } + }); } diff --git a/src/backend/database/routes/user-oidc-utils.test.ts b/src/backend/database/routes/user-oidc-utils.test.ts index 33c2bdc8e..7ce97ade5 100644 --- a/src/backend/database/routes/user-oidc-utils.test.ts +++ b/src/backend/database/routes/user-oidc-utils.test.ts @@ -61,6 +61,23 @@ describe("isOIDCUserAllowed", () => { it("does not match the email against an identifier-only pattern when email differs", () => { expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false); }); + + it("matches *@domain.com wildcard pattern against emails", () => { + expect( + isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"), + ).toBe(true); + expect( + isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"), + ).toBe(true); + expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe( + false, + ); + }); + + it("matches glob patterns with multiple wildcards", () => { + expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true); + expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false); + }); }); describe("getOIDCConfigFromEnv", () => { diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts index 3e225fd31..bdc71c182 100644 --- a/src/backend/database/routes/user-oidc-utils.ts +++ b/src/backend/database/routes/user-oidc-utils.ts @@ -4,6 +4,7 @@ import { db } from "../db/index.js"; import { ssoProviders } from "../db/schema.js"; import { eq } from "drizzle-orm"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { Agent } from "undici"; export type OIDCConfig = { client_id: string; @@ -18,8 +19,14 @@ export type OIDCConfig = { allowed_users: string; admin_group: string; group_claim?: string; + ca_cert?: string; }; +export function buildFetchOptions(caCert?: string): Record { + if (!caCert || !caCert.trim()) return {}; + return { dispatcher: new Agent({ connect: { ca: caCert } }) }; +} + export function getOIDCConfigFromEnv(): OIDCConfig | null { const client_id = process.env.OIDC_CLIENT_ID; const client_secret = process.env.OIDC_CLIENT_SECRET; @@ -107,6 +114,15 @@ export function isOIDCUserAllowed( ]; for (const pattern of patterns) { if (pattern === "*") return true; + if (pattern.includes("*")) { + const escaped = pattern + .toLowerCase() + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + const regex = new RegExp(`^${escaped}$`); + if (values.some((v) => v && regex.test(v.toLowerCase()))) return true; + continue; + } for (const value of values) { if (!value) continue; if (pattern.toLowerCase().startsWith("@")) { @@ -123,7 +139,9 @@ export async function verifyOIDCToken( idToken: string, issuerUrl: string, clientId: string, + caCert?: string, ): Promise> { + const fetchOptions = buildFetchOptions(caCert); const normalizedIssuerUrl = issuerUrl.endsWith("/") ? issuerUrl.slice(0, -1) : issuerUrl; @@ -142,7 +160,7 @@ export async function verifyOIDCToken( try { const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; - const discoveryResponse = await fetch(discoveryUrl); + const discoveryResponse = await fetch(discoveryUrl, fetchOptions); if (discoveryResponse.ok) { const discovery = (await discoveryResponse.json()) as Record< string, @@ -160,7 +178,7 @@ export async function verifyOIDCToken( for (const url of jwksUrls) { try { - const response = await fetch(url); + const response = await fetch(url, fetchOptions); if (response.ok) { const jwksData = (await response.json()) as Record; if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) { diff --git a/src/backend/database/routes/user-password-reset-routes.ts b/src/backend/database/routes/user-password-reset-routes.ts index 8a1c85840..f44293e26 100644 --- a/src/backend/database/routes/user-password-reset-routes.ts +++ b/src/backend/database/routes/user-password-reset-routes.ts @@ -63,12 +63,19 @@ export function registerUserPasswordResetRoutes( */ router.post("/initiate-reset", async (req, res) => { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'allow_password_reset'", - ) - .get(); - if (row && (row as { value: string }).value !== "true") { + const envVal = process.env.ALLOW_PASSWORD_RESET; + const allowed = + envVal !== undefined + ? envVal.trim().toLowerCase() === "true" + : (() => { + const row = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'allow_password_reset'", + ) + .get(); + return row ? (row as { value: string }).value === "true" : true; + })(); + if (!allowed) { return res .status(403) .json({ error: "Password reset is currently disabled" }); diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index 74bb23924..c99d60540 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -28,6 +28,8 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ disableUpdateCheck: row?.disableUpdateCheck ?? null, confirmTabClose: row?.confirmTabClose ?? null, hiddenRailTabs: row?.hiddenRailTabs ?? null, + compactHostView: row?.compactHostView ?? null, + statusColorScheme: row?.statusColorScheme ?? null, }); /** @@ -92,6 +94,12 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ * hiddenRailTabs: * type: string * nullable: true + * compactHostView: + * type: boolean + * nullable: true + * statusColorScheme: + * type: string + * nullable: true */ router.get("/", authenticateJWT, (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -158,6 +166,10 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => { * type: boolean * hiddenRailTabs: * type: string + * compactHostView: + * type: boolean + * statusColorScheme: + * type: string * responses: * 200: * description: Preferences updated successfully. @@ -181,6 +193,8 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { disableUpdateCheck, confirmTabClose, hiddenRailTabs, + compactHostView, + statusColorScheme, } = req.body as { reopenTabsOnLogin?: boolean; theme?: string | null; @@ -198,6 +212,8 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { disableUpdateCheck?: boolean | null; confirmTabClose?: boolean | null; hiddenRailTabs?: string | null; + compactHostView?: boolean | null; + statusColorScheme?: string | null; }; const updates: Partial = { @@ -220,6 +236,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { language, storageMode, hiddenRailTabs, + statusColorScheme, })) { if (value !== undefined && value !== null && typeof value !== "string") { return res.status(400).json({ error: `${key} must be a string` }); @@ -236,6 +253,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { confirmSnippetExecution, disableUpdateCheck, confirmTabClose, + compactHostView, }; for (const [key, value] of Object.entries(boolFields)) { if (value !== undefined && value !== null && typeof value !== "boolean") { @@ -263,6 +281,9 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { if (disableUpdateCheck !== undefined) updates.disableUpdateCheck = disableUpdateCheck; if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose; + if (compactHostView !== undefined) updates.compactHostView = compactHostView; + if (statusColorScheme !== undefined) + updates.statusColorScheme = statusColorScheme; 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 9083978f8..2d82cbd9f 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -15,6 +15,24 @@ function getDefaultGuacUrl(): string { return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`; } +export type HostDefaults = { + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + credentialId?: number | null; + metricsEnabled?: boolean; + statusCheckEnabled?: boolean; + fontSize?: number; + fontFamily?: string; + theme?: string; + cursorStyle?: string; + cursorBlink?: boolean; + enableSessionLogging?: boolean; + enableCommandHistory?: boolean; +}; + export function registerUserSettingsRoutes( router: Router, authenticateJWT: RequestHandler, @@ -544,4 +562,91 @@ export function registerUserSettingsRoutes( } }, ); + + /** + * @openapi + * /users/host-defaults: + * get: + * summary: Get host creation defaults + * description: Returns the global default settings applied when creating a new host. + * tags: + * - Users + * responses: + * 200: + * description: Host defaults object. + * 500: + * description: Failed to get host defaults. + */ + router.get("/host-defaults", authenticateJWT, async (_req, res) => { + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'host_defaults'") + .get() as { value: string } | undefined; + const defaults: HostDefaults = row ? JSON.parse(row.value) : {}; + res.json(defaults); + } catch (err) { + authLogger.error("Failed to get host defaults", err); + res.status(500).json({ error: "Failed to get host defaults" }); + } + }); + + /** + * @openapi + * /users/host-defaults: + * patch: + * summary: Update host creation defaults (admin only) + * description: Sets global default settings applied when a new host is created. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * responses: + * 200: + * description: Host defaults updated. + * 403: + * description: Not authorized. + * 500: + * description: Failed to update host defaults. + */ + router.patch("/host-defaults", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + const defaults: HostDefaults = req.body; + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('host_defaults', ?)", + ) + .run(JSON.stringify(defaults)); + + const { ipAddress, userAgent } = getRequestMeta(req); + const actorRecord = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + await logAudit({ + userId, + username: actorRecord[0]?.username ?? userId, + action: "update_host_defaults", + resourceType: "setting", + details: JSON.stringify(defaults), + ipAddress, + userAgent, + success: true, + }); + + res.json(defaults); + } catch (err) { + authLogger.error("Failed to update host defaults", err); + res.status(500).json({ error: "Failed to update host defaults" }); + } + }); } diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index b2843cc02..e33fb71f2 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -22,9 +22,11 @@ import { verifyOIDCToken, extractOidcGroups, loadProviderConfig, + buildFetchOptions, } from "./user-oidc-utils.js"; import { registerUserApiKeyRoutes } from "./user-api-key-routes.js"; import { registerUserSettingsRoutes } from "./user-settings-routes.js"; +import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js"; import { registerUserTotpRoutes } from "./user-totp-routes.js"; import { registerUserSessionRoutes } from "./user-session-routes.js"; import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js"; @@ -43,6 +45,45 @@ function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } +function isRegistrationAllowed(): boolean { + const envVal = process.env.ALLOW_REGISTRATION; + if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") + .get() as { value: string } | undefined; + return row ? row.value === "true" : true; + } catch { + return true; + } +} + +function isPasswordLoginAllowed(): boolean { + const envVal = process.env.ALLOW_PASSWORD_LOGIN; + if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") + .get() as { value: string } | undefined; + return row ? row.value === "true" : true; + } catch { + return true; + } +} + +function isPasswordResetAllowed(): boolean { + const envVal = process.env.ALLOW_PASSWORD_RESET; + if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; + try { + const row = db.$client + .prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'") + .get() as { value: string } | undefined; + return row ? row.value === "true" : true; + } catch { + return true; + } +} + function isNativeAppRequest(req: Request): boolean { return ( (req.get("User-Agent") || "").startsWith("Termix-Mobile/") || @@ -85,20 +126,10 @@ const requireAdmin = authManager.createAdminMiddleware(); * description: Failed to create user. */ router.post("/create", async (req, res) => { - try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") - .get(); - if (row && (row as Record).value !== "true") { - return res - .status(403) - .json({ error: "Registration is currently disabled" }); - } - } catch (e) { - authLogger.warn("Failed to check registration status", { - operation: "registration_check", - error: e, - }); + if (!isRegistrationAllowed()) { + return res + .status(403) + .json({ error: "Registration is currently disabled" }); } const { username, password } = req.body; @@ -737,6 +768,9 @@ router.get("/oidc/callback", async (req, res) => { .run(`oidc_provider_${state}`); } + const caCert = config.ca_cert; + const fetchOptions = buildFetchOptions(caCert); + // GitHub does not issue OIDC id_tokens; handle its token exchange separately if (callbackProviderType === "github") { const ghTokenResponse = await fetch(config.token_url, { @@ -752,6 +786,7 @@ router.get("/oidc/callback", async (req, res) => { code: code, redirect_uri: backendCallbackUri, }), + ...fetchOptions, }); if (!ghTokenResponse.ok) { @@ -789,6 +824,7 @@ router.get("/oidc/callback", async (req, res) => { Accept: "application/json", "User-Agent": "Termix", }, + ...fetchOptions, }); if (!ghUserInfoResponse.ok) { return res @@ -859,16 +895,9 @@ router.get("/oidc/callback", async (req, res) => { "true"; if (!isFirstUser && !ghAutoProvision) { - const regRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'allow_registration'", - ) - .get() as { value: string } | undefined; - if (regRow && regRow.value !== "true") { - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("error", "registration_disabled"); - return res.redirect(redirectUrl.toString()); - } + const redirectUrl = new URL(frontendOrigin); + redirectUrl.searchParams.set("error", "registration_disabled"); + return res.redirect(redirectUrl.toString()); } const ghId = nanoid(); @@ -980,6 +1009,7 @@ router.get("/oidc/callback", async (req, res) => { code: code, redirect_uri: backendCallbackUri, }), + ...fetchOptions, }); if (!tokenResponse.ok) { @@ -1022,7 +1052,7 @@ router.get("/oidc/callback", async (req, res) => { try { const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; - const discoveryResponse = await fetch(discoveryUrl); + const discoveryResponse = await fetch(discoveryUrl, fetchOptions); if (discoveryResponse.ok) { const discovery = (await discoveryResponse.json()) as Record< string, @@ -1057,6 +1087,7 @@ router.get("/oidc/callback", async (req, res) => { tokenData.id_token as string, config.issuer_url, config.client_id, + caCert, ); } catch { try { @@ -1080,6 +1111,7 @@ router.get("/oidc/callback", async (req, res) => { headers: { Authorization: `Bearer ${tokenData.access_token}`, }, + ...fetchOptions, }); if (userInfoResponse.ok) { @@ -1189,33 +1221,17 @@ router.get("/oidc/callback", async (req, res) => { } if (!isFirstUser && !oidcAutoProvision) { - try { - const regRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'allow_registration'", - ) - .get(); - if (regRow && (regRow as Record).value !== "true") { - authLogger.warn( - "OIDC user attempted to register when registration is disabled", - { - operation: "oidc_registration_disabled", - identifier, - name, - }, - ); - - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("error", "registration_disabled"); - - return res.redirect(redirectUrl.toString()); - } - } catch (e) { - authLogger.warn("Failed to check registration status during OIDC", { - operation: "oidc_registration_check", - error: e, - }); - } + authLogger.warn( + "OIDC user attempted to register but auto-provisioning is disabled", + { + operation: "oidc_registration_disabled", + identifier, + name, + }, + ); + const redirectUrl = new URL(frontendOrigin); + redirectUrl.searchParams.set("error", "registration_disabled"); + return res.redirect(redirectUrl.toString()); } const id = nanoid(); @@ -1536,21 +1552,10 @@ router.post("/login", async (req, res) => { }); } - try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") - .get(); - if (row && (row as { value: string }).value !== "true") { - return res - .status(403) - .json({ error: "Password authentication is currently disabled" }); - } - } catch (e) { - authLogger.error("Failed to check password login status", { - operation: "login_check", - error: e, - }); - return res.status(500).json({ error: "Failed to check login status" }); + if (!isPasswordLoginAllowed()) { + return res + .status(403) + .json({ error: "Password authentication is currently disabled" }); } try { @@ -1951,12 +1956,7 @@ router.get("/db-health", requireAdmin, async (req, res) => { */ router.get("/registration-allowed", async (req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") - .get(); - res.json({ - allowed: row ? (row as Record).value === "true" : true, - }); + res.json({ allowed: isRegistrationAllowed() }); } catch (err) { authLogger.error("Failed to get registration allowed", err); res.status(500).json({ error: "Failed to get registration allowed" }); @@ -2061,6 +2061,107 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { } }); +/** + * @openapi + * /users/oidc-silent-login-default: + * get: + * summary: Get OIDC silent login default setting + * description: Returns whether silent OIDC login is enabled as the default behavior. + * tags: + * - Users + * responses: + * 200: + * description: Silent login default setting. + * 500: + * description: Failed to get setting. + */ +router.get("/oidc-silent-login-default", async (_req, res) => { + try { + const row = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'oidc_silent_login_default'", + ) + .get(); + res.json({ + enabled: row ? (row as Record).value === "true" : false, + }); + } catch (err) { + authLogger.error("Failed to get OIDC silent login default", err); + res.status(500).json({ error: "Failed to get OIDC silent login default" }); + } +}); + +/** + * @openapi + * /users/oidc-silent-login-default: + * patch: + * summary: Set OIDC silent login default setting + * description: Enables or disables silent OIDC login as the default behavior on the login page. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * responses: + * 200: + * description: Setting updated. + * 400: + * description: Invalid value. + * 403: + * description: Not authorized. + * 500: + * description: Failed to update setting. + */ +router.patch( + "/oidc-silent-login-default", + authenticateJWT, + async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + const { enabled } = req.body; + if (typeof enabled !== "boolean") { + return res.status(400).json({ error: "Invalid value for enabled" }); + } + const existing = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'oidc_silent_login_default'", + ) + .get(); + if (existing) { + db.$client + .prepare( + "UPDATE settings SET value = ? WHERE key = 'oidc_silent_login_default'", + ) + .run(enabled ? "true" : "false"); + } else { + db.$client + .prepare( + "INSERT INTO settings (key, value) VALUES ('oidc_silent_login_default', ?)", + ) + .run(enabled ? "true" : "false"); + } + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + res.json({ enabled }); + } catch (err) { + authLogger.error("Failed to set OIDC silent login default", err); + res + .status(500) + .json({ error: "Failed to set OIDC silent login default" }); + } + }, +); + /** * @openapi * /users/password-login-allowed: @@ -2077,12 +2178,7 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { */ router.get("/password-login-allowed", async (req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") - .get(); - res.json({ - allowed: row ? (row as { value: string }).value === "true" : true, - }); + res.json({ allowed: isPasswordLoginAllowed() }); } catch (err) { authLogger.error("Failed to get password login allowed", err); res.status(500).json({ error: "Failed to get password login allowed" }); @@ -2168,12 +2264,7 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { */ router.get("/password-reset-allowed", async (req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'") - .get(); - res.json({ - allowed: row ? (row as { value: string }).value === "true" : true, - }); + res.json({ allowed: isPasswordResetAllowed() }); } catch (err) { authLogger.error("Failed to get password reset allowed", err); res.status(500).json({ error: "Failed to get password reset allowed" }); @@ -2561,6 +2652,7 @@ registerUserOidcAccountRoutes(router, { }); registerUserSettingsRoutes(router, authenticateJWT); +registerAcmeSSLRoutes(router, authenticateJWT); registerUserApiKeyRoutes(router, requireAdmin); diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts index fcb58c2be..dbcca5ab1 100644 --- a/src/backend/guacamole/routes.ts +++ b/src/backend/guacamole/routes.ts @@ -5,8 +5,8 @@ import { AuthManager } from "../utils/auth-manager.js"; import { PermissionManager } from "../utils/permission-manager.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { getDb } from "../database/db/index.js"; -import { hosts } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; +import { hosts, sshCredentials } from "../database/db/schema.js"; +import { eq, and } from "drizzle-orm"; import { Client } from "ssh2"; import net from "net"; import type { AuthenticatedRequest } from "../../types/index.js"; @@ -275,12 +275,84 @@ router.post( } } + // Extract per-connection guacd proxy settings before passing the rest as connection settings + const perConnectionGuacdHost = guacConfig["guacd-hostname"] as + | string + | undefined; + const perConnectionGuacdPortRaw = guacConfig["guacd-port"]; + const perConnectionGuacdPort = perConnectionGuacdPortRaw + ? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined + : undefined; + delete guacConfig["guacd-hostname"]; + delete guacConfig["guacd-port"]; + if (guacConfig.dpi != null) { const parsed = parseInt(String(guacConfig.dpi), 10); guacConfig.dpi = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } + // Resolve RDP/VNC shared credentials if set + if (host.rdpCredentialId) { + try { + const rdpCreds = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.rdpCredentialId as number), + eq(sshCredentials.userId, host.userId as string), + ), + ), + "ssh_credentials", + userId, + ); + if (rdpCreds.length > 0) { + const cred = rdpCreds[0] as Record; + if (!host.rdpUser && cred.username) host.rdpUser = cred.username; + if (!host.rdpPassword && cred.password) + host.rdpPassword = cred.password; + } + } catch (e) { + guacLogger.warn("Failed to resolve RDP credential", { + operation: "guac_rdp_credential_resolve", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + } + + if (host.vncCredentialId) { + try { + const vncCreds = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.vncCredentialId as number), + eq(sshCredentials.userId, host.userId as string), + ), + ), + "ssh_credentials", + userId, + ); + if (vncCreds.length > 0) { + const cred = vncCreds[0] as Record; + if (!host.vncUser && cred.username) host.vncUser = cred.username; + if (!host.vncPassword && cred.password) + host.vncPassword = cred.password; + } + } catch (e) { + guacLogger.warn("Failed to resolve VNC credential", { + operation: "guac_vnc_credential_resolve", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + } + let token: string; let hostname = host.ip as string; let port = host.port as number; @@ -399,6 +471,15 @@ router.post( } } + const guacdOverrides = { + ...(perConnectionGuacdHost + ? { guacdHost: perConnectionGuacdHost } + : {}), + ...(perConnectionGuacdPort + ? { guacdPort: perConnectionGuacdPort } + : {}), + }; + switch (connectionType) { case "rdp": if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) { @@ -419,6 +500,7 @@ router.post( ? !!host.ignoreCert : true, ...guacConfig, + ...guacdOverrides, }); break; case "vnc": @@ -430,6 +512,7 @@ router.post( port, security: "any", ...guacConfig, + ...guacdOverrides, }, ); break; @@ -437,6 +520,7 @@ router.post( token = tokenService.createTelnetToken(hostname, username, password, { port, ...guacConfig, + ...guacdOverrides, }); break; default: diff --git a/src/backend/guacamole/token-service.ts b/src/backend/guacamole/token-service.ts index 83a2c93a9..0142ec94e 100644 --- a/src/backend/guacamole/token-service.ts +++ b/src/backend/guacamole/token-service.ts @@ -3,6 +3,8 @@ import { guacLogger } from "../utils/logger.js"; export interface GuacamoleConnectionSettings { type: "rdp" | "vnc" | "telnet"; + guacdHost?: string; + guacdPort?: number; settings: { hostname: string; port?: number; @@ -120,18 +122,24 @@ export class GuacamoleTokenService { hostname: string, username: string, password: string, - options: Partial = {}, + options: Partial & { + guacdHost?: string; + guacdPort?: number; + } = {}, ): string { + const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { connection: { type: "rdp", + ...(guacdHost ? { guacdHost } : {}), + ...(guacdPort ? { guacdPort } : {}), settings: { hostname, - username, - password, + ...(username ? { username } : {}), + ...(password ? { password } : {}), port: 3389, "ignore-cert": true, - ...options, + ...settingsOptions, }, }, }; @@ -142,17 +150,23 @@ export class GuacamoleTokenService { hostname: string, username?: string, password?: string, - options: Partial = {}, + options: Partial & { + guacdHost?: string; + guacdPort?: number; + } = {}, ): string { + const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { connection: { type: "vnc", + ...(guacdHost ? { guacdHost } : {}), + ...(guacdPort ? { guacdPort } : {}), settings: { hostname, ...(username ? { username } : {}), password, port: 5900, - ...options, + ...settingsOptions, }, }, }; @@ -163,17 +177,23 @@ export class GuacamoleTokenService { hostname: string, username?: string, password?: string, - options: Partial = {}, + options: Partial & { + guacdHost?: string; + guacdPort?: number; + } = {}, ): string { + const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { connection: { type: "telnet", + ...(guacdHost ? { guacdHost } : {}), + ...(guacdPort ? { guacdPort } : {}), settings: { hostname, username, password, port: 23, - ...options, + ...settingsOptions, }, }, }; diff --git a/src/backend/ssh/credential-username.test.ts b/src/backend/ssh/credential-username.test.ts index fef686dbd..fc6a56e4c 100644 --- a/src/backend/ssh/credential-username.test.ts +++ b/src/backend/ssh/credential-username.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from "vitest"; -import { pickResolvedUsername } from "./credential-username.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + pickResolvedUsername, + expandOidcUsername, +} from "./credential-username.js"; describe("pickResolvedUsername", () => { it("keeps the host username when one is set, even with a credential username", () => { @@ -26,3 +29,70 @@ describe("pickResolvedUsername", () => { expect(pickResolvedUsername(undefined, undefined, false)).toBeUndefined(); }); }); + +describe("expandOidcUsername", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns the username unchanged when it has no placeholder", async () => { + expect(await expandOidcUsername("alice", "user-1")).toBe("alice"); + expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined(); + }); + + it("expands the placeholder with the user's OIDC identifier", async () => { + vi.doMock("../database/db/index.js", () => ({ + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [{ oidcIdentifier: "jdoe" }], + }), + }), + }), + }), + })); + vi.doMock("../database/db/schema.js", () => ({ users: {} })); + vi.doMock("drizzle-orm", () => ({ eq: vi.fn() })); + + const { expandOidcUsername: expand } = + await import("./credential-username.js"); + expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe"); + }); + + it("leaves the placeholder as-is when the user has no OIDC identifier", async () => { + vi.doMock("../database/db/index.js", () => ({ + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [{ oidcIdentifier: null }], + }), + }), + }), + }), + })); + vi.doMock("../database/db/schema.js", () => ({ users: {} })); + vi.doMock("drizzle-orm", () => ({ eq: vi.fn() })); + + const { expandOidcUsername: expand } = + await import("./credential-username.js"); + expect(await expand("$oidc.preferred_username", "user-1")).toBe( + "$oidc.preferred_username", + ); + }); + + it("returns the username unchanged when the DB lookup throws", async () => { + vi.doMock("../database/db/index.js", () => ({ + getDb: () => { + throw new Error("DB unavailable"); + }, + })); + + const { expandOidcUsername: expand } = + await import("./credential-username.js"); + expect(await expand("$oidc.preferred_username", "user-1")).toBe( + "$oidc.preferred_username", + ); + }); +}); diff --git a/src/backend/ssh/credential-username.ts b/src/backend/ssh/credential-username.ts index e0e155779..26f6d9825 100644 --- a/src/backend/ssh/credential-username.ts +++ b/src/backend/ssh/credential-username.ts @@ -21,6 +21,40 @@ export function pickResolvedUsername( return cred; } +/** + * Expands the `$oidc.preferred_username` placeholder in an SSH username to the + * connecting user's OIDC identifier. Returns the username unchanged if it does + * not contain the placeholder or the user has no OIDC identifier. + */ +export async function expandOidcUsername( + username: string | undefined, + userId: string, +): Promise { + if (!username || !username.includes("$oidc.preferred_username")) { + return username; + } + + try { + const { getDb } = await import("../database/db/index.js"); + const { users } = await import("../database/db/schema.js"); + const { eq } = await import("drizzle-orm"); + + const db = getDb(); + const rows = await db + .select({ oidcIdentifier: users.oidcIdentifier }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + const oidcIdentifier = rows[0]?.oidcIdentifier; + if (!oidcIdentifier) return username; + + return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier); + } catch { + return username; + } +} + function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim() !== ""; } diff --git a/src/backend/ssh/file-manager-download-routes.ts b/src/backend/ssh/file-manager-download-routes.ts index 75a806388..50ac9e032 100644 --- a/src/backend/ssh/file-manager-download-routes.ts +++ b/src/backend/ssh/file-manager-download-routes.ts @@ -2,7 +2,11 @@ import type { Express } from "express"; import type { AuthenticatedRequest } from "../../types/index.js"; import { fileLogger } from "../utils/logger.js"; import { getMimeType } from "./file-manager-utils.js"; -import { getSessionSftp, type SSHSession } from "./file-manager-session.js"; +import { + execChannel, + getSessionSftp, + type SSHSession, +} from "./file-manager-session.js"; type FileDownloadRoutesDeps = { sshSessions: Record; @@ -10,6 +14,37 @@ type FileDownloadRoutesDeps = { verifySessionOwnership: (session: SSHSession, userId: string) => boolean; }; +function escapeSingleQuotes(path: string): string { + return path.replace(/'/g, "'\"'\"'"); +} + +function downloadViaExec( + session: SSHSession, + filePath: string, +): Promise { + return new Promise((resolve, reject) => { + const escaped = escapeSingleQuotes(filePath); + execChannel(session, `cat '${escaped}'`, (err, stream) => { + if (err) return reject(err); + const chunks: Buffer[] = []; + let stderr = ""; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + stream.on("close", (code: number) => { + if (code !== 0) { + return reject( + new Error(stderr.trim() || `cat exited with code ${code}`), + ); + } + resolve(Buffer.concat(chunks)); + }); + stream.on("error", reject); + }); + }); +} + export function registerFileDownloadRoutes( app: Express, { @@ -23,7 +58,7 @@ export function registerFileDownloadRoutes( * /ssh/file_manager/ssh/downloadFile: * post: * summary: Download a file - * description: Downloads a file from the remote host. + * description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP. * tags: * - File Manager * requestBody: @@ -88,6 +123,47 @@ export function registerFileDownloadRoutes( sshConn.lastActive = Date.now(); scheduleSessionCleanup(sessionId); + + if (sshConn.scpLegacy) { + fileLogger.info( + "Downloading file via legacy exec/cat (SCP legacy mode)", + { + operation: "file_download_legacy", + sessionId, + userId, + path: filePath, + }, + ); + + try { + const data = await downloadViaExec(sshConn, filePath); + const fileName = filePath.split("/").pop() || "download"; + fileLogger.success("File download completed (legacy mode)", { + operation: "file_download_complete", + sessionId, + userId, + hostId, + path: filePath, + bytes: data.length, + duration: Date.now() - downloadStartTime, + }); + return res.json({ + content: data.toString("base64"), + fileName, + size: data.length, + mimeType: getMimeType(fileName), + path: filePath, + }); + } catch (err) { + fileLogger.error("Legacy exec/cat download failed:", err); + return res + .status(500) + .json({ + error: `Failed to download file: ${(err as Error).message}`, + }); + } + } + fileLogger.info("Opening SFTP channel", { operation: "file_sftp_open", sessionId, @@ -168,6 +244,33 @@ export function registerFileDownloadRoutes( }); }); + /** + * @openapi + * /ssh/file_manager/ssh/downloadFileStream: + * post: + * summary: Stream-download a file + * description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * path: + * type: string + * responses: + * 200: + * description: Binary file stream. + * 400: + * description: Missing required parameters. + * 500: + * description: Download failed. + */ app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => { const { sessionId, path: filePath } = req.body; const userId = (req as AuthenticatedRequest).userId; @@ -188,6 +291,43 @@ export function registerFileDownloadRoutes( sshConn.lastActive = Date.now(); + const fileName = filePath.split("/").pop() || "download"; + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${encodeURIComponent(fileName)}"`, + ); + + if (sshConn.scpLegacy) { + fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", { + operation: "file_download_stream_legacy", + sessionId, + userId, + path: filePath, + }); + + const escaped = escapeSingleQuotes(filePath); + execChannel(sshConn, `cat '${escaped}'`, (err, stream) => { + if (err) { + if (!res.headersSent) { + res.status(500).json({ error: `Download failed: ${err.message}` }); + } + return; + } + stream.on("error", (streamErr: Error) => { + if (!res.headersSent) { + res + .status(500) + .json({ error: `Download failed: ${streamErr.message}` }); + } else { + res.destroy(); + } + }); + stream.pipe(res); + }); + return; + } + try { const sftp = await getSessionSftp(sshConn); const stats = await new Promise<{ size: number; isFile: () => boolean }>( @@ -200,12 +340,6 @@ export function registerFileDownloadRoutes( return res.status(400).json({ error: "Cannot download directories" }); } - const fileName = filePath.split("/").pop() || "download"; - res.setHeader("Content-Type", "application/octet-stream"); - res.setHeader( - "Content-Disposition", - `attachment; filename="${encodeURIComponent(fileName)}"`, - ); res.setHeader("Content-Length", String(stats.size)); const readStream = sftp.createReadStream(filePath); diff --git a/src/backend/ssh/file-manager-session.ts b/src/backend/ssh/file-manager-session.ts index 1bbebf223..8c11081e2 100644 --- a/src/backend/ssh/file-manager-session.ts +++ b/src/backend/ssh/file-manager-session.ts @@ -39,6 +39,7 @@ export interface SSHSession { transferDedicated?: boolean; transferId?: string; browseSessionId?: string; + scpLegacy?: boolean; } export interface PendingTOTPSession { diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 5562cc9f0..df9c55943 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -732,6 +732,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { let resolvedPort = port; let resolvedUsername = username; let resolvedJumpHosts = jumpHosts; + let resolvedScpLegacy = false; let resolvedUseSocks5 = useSocks5; let resolvedSocks5Host = socks5Host; let resolvedSocks5Port = socks5Port; @@ -755,6 +756,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }; hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; + resolvedScpLegacy = resolvedHost.scpLegacy ?? false; if (resolvedHost.useSocks5) { resolvedUseSocks5 = resolvedHost.useSocks5; resolvedSocks5Host = resolvedHost.socks5Host; @@ -810,6 +812,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }; hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; + resolvedScpLegacy = resolvedHost.scpLegacy ?? false; if (resolvedHost.useSocks5) { resolvedUseSocks5 = resolvedHost.useSocks5; resolvedSocks5Host = resolvedHost.socks5Host; @@ -1142,6 +1145,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { hostId, username, sudoPassword: resolvedCredentials.sudoPassword, + scpLegacy: resolvedScpLegacy, }; scheduleSessionCleanup(sessionId); res.json({ diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts index 091a6c535..a856e5366 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -3,7 +3,10 @@ import { hosts, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { logger } from "../utils/logger.js"; -import { pickResolvedUsername } from "./credential-username.js"; +import { + pickResolvedUsername, + expandOidcUsername, +} from "./credential-username.js"; import type { SSHHost } from "../../types/index.js"; const sshLogger = logger; @@ -120,6 +123,10 @@ export async function resolveHostById( : cred.password ? "password" : "none"; + host.username = await expandOidcUsername( + host.username as string | undefined, + userId, + ); return host as unknown as SSHHost; } } @@ -150,6 +157,10 @@ export async function resolveHostById( : sharedCred.password ? "password" : "none"; + host.username = await expandOidcUsername( + host.username as string | undefined, + userId, + ); return host as unknown as SSHHost; } } catch (e) { @@ -203,6 +214,11 @@ export async function resolveHostById( } } + host.username = await expandOidcUsername( + host.username as string | undefined, + userId, + ); + return host as unknown as SSHHost; } diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 5f3b42fa5..37446de95 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -5,7 +5,7 @@ import ssh2Pkg, { type PseudoTtyOptions, } from "ssh2"; const { Client, utils: ssh2Utils } = ssh2Pkg; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; +import { buildSSHAlgorithms } from "../utils/ssh-algorithms.js"; import axios from "axios"; import { getDb } from "../database/db/index.js"; import { hosts } from "../database/db/schema.js"; @@ -473,6 +473,55 @@ wss.on("connection", async (ws: WebSocket, req) => { break; } + case "open_file_in_editor": { + const { path: requestedPath } = data as { path: string }; + const activeConn = + sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn; + if (!activeConn || !requestedPath) { + ws.send( + JSON.stringify({ + type: "open_file_in_editor", + path: requestedPath || "/", + }), + ); + break; + } + const escapedPath = requestedPath.replace(/'/g, "'\\''"); + activeConn.exec( + `realpath '${escapedPath}' 2>/dev/null || echo '${escapedPath}'`, + (err, execStream) => { + if (err) { + ws.send( + JSON.stringify({ + type: "open_file_in_editor", + path: requestedPath, + }), + ); + return; + } + let stdout = ""; + execStream.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf-8"); + }); + execStream.stderr.on("data", () => {}); + execStream.on("close", () => { + const resolvedPath = stdout.trim() || requestedPath; + const attachedWs = + sessionManager.getSession(currentSessionId)?.attachedWs ?? ws; + if (attachedWs.readyState === WebSocket.OPEN) { + attachedWs.send( + JSON.stringify({ + type: "open_file_in_editor", + path: resolvedPath, + }), + ); + } + }); + }, + ); + break; + } + case "input": { const inputData = data as string; const inputStream = @@ -1534,27 +1583,6 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); runPostShellCommands(0); - } else if (detection.sessions.length === 1) { - attachOrCreateTmuxSession(stream, detection.sessions[0].name); - const sessionName = detection.sessions[0].name; - const session = sessionManager.getSession(boundSessionId); - if (session) { - session.tmuxSessionName = sessionName; - } - sshLogger.info("Auto-attached to existing tmux session", { - operation: "tmux_auto_attach", - sessionName, - hostId: id, - }); - ws.send( - JSON.stringify({ - type: "tmux_session_attached", - sessionName, - }), - ); - // Reattaching to existing session -- don't re-run - // initialPath/executeCommand since the session already - // has its own state } else { sshLogger.info( "Multiple tmux sessions found, sending list to frontend", @@ -2042,44 +2070,9 @@ wss.on("connection", async (ws: WebSocket, req) => { LC_COLLATE: "en_US.UTF-8", COLORTERM: "truecolor", }, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group18-sha512", - "diffie-hellman-group17-sha512", - "diffie-hellman-group16-sha512", - "diffie-hellman-group15-sha512", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, + algorithms: buildSSHAlgorithms( + hostConfig.terminalConfig?.allowLegacyAlgorithms !== false, + ), }; if ( diff --git a/src/backend/utils/ssh-algorithms.ts b/src/backend/utils/ssh-algorithms.ts index caece95f6..40a591227 100644 --- a/src/backend/utils/ssh-algorithms.ts +++ b/src/backend/utils/ssh-algorithms.ts @@ -1,6 +1,12 @@ import crypto from "crypto"; import { createRequire } from "module"; -import type { ConnectConfig, CipherAlgorithm } from "ssh2"; +import type { + ConnectConfig, + CipherAlgorithm, + KexAlgorithm, + ServerHostKeyAlgorithm, + MacAlgorithm, +} from "ssh2"; const nativeRequire = createRequire(import.meta.url); const availableCiphers = new Set(crypto.getCiphers()); @@ -47,6 +53,76 @@ function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] { }); } +const LEGACY_KEX: KexAlgorithm[] = [ + "diffie-hellman-group14-sha1", + "diffie-hellman-group-exchange-sha1", + "diffie-hellman-group1-sha1", +]; + +const LEGACY_SERVER_HOST_KEY: ServerHostKeyAlgorithm[] = ["ssh-rsa", "ssh-dss"]; + +const LEGACY_HMAC: MacAlgorithm[] = ["hmac-sha1", "hmac-md5"]; + +const LEGACY_CIPHER = filterCiphers(["3des-cbc"]); + +export function buildSSHAlgorithms( + allowLegacy: boolean, +): NonNullable { + const kex: KexAlgorithm[] = [ + "curve25519-sha256", + "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp521", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp256", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group18-sha512", + "diffie-hellman-group17-sha512", + "diffie-hellman-group16-sha512", + "diffie-hellman-group15-sha512", + "diffie-hellman-group14-sha256", + ]; + const serverHostKey: ServerHostKeyAlgorithm[] = [ + "ssh-ed25519", + "ecdsa-sha2-nistp521", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp256", + "rsa-sha2-512", + "rsa-sha2-256", + ]; + const hmac: MacAlgorithm[] = [ + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512", + "hmac-sha2-256", + ]; + const cipher = filterCiphers([ + "chacha20-poly1305@openssh.com", + "aes256-gcm@openssh.com", + "aes128-gcm@openssh.com", + "aes256-ctr", + "aes192-ctr", + "aes128-ctr", + "aes256-cbc", + "aes192-cbc", + "aes128-cbc", + ]); + + if (allowLegacy) { + kex.push(...LEGACY_KEX); + serverHostKey.push(...LEGACY_SERVER_HOST_KEY); + hmac.push(...LEGACY_HMAC); + cipher.push(...LEGACY_CIPHER); + } + + return { + kex, + serverHostKey, + cipher, + hmac, + compress: ["none", "zlib@openssh.com", "zlib"], + }; +} + export const SSH_ALGORITHMS: NonNullable = { kex: [ "curve25519-sha256", diff --git a/src/backend/utils/wake-on-lan.test.ts b/src/backend/utils/wake-on-lan.test.ts index 0a53ced41..eb681d06e 100644 --- a/src/backend/utils/wake-on-lan.test.ts +++ b/src/backend/utils/wake-on-lan.test.ts @@ -1,5 +1,18 @@ -import { describe, it, expect } from "vitest"; -import { isValidMac, buildMagicPacket } from "./wake-on-lan.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("dgram", () => { + const socket = { + once: vi.fn(), + bind: vi.fn(), + setBroadcast: vi.fn(), + send: vi.fn(), + close: vi.fn(), + }; + return { default: { createSocket: vi.fn(() => socket) } }; +}); + +import dgram from "dgram"; +import { isValidMac, buildMagicPacket, sendWakeOnLan } from "./wake-on-lan.js"; describe("isValidMac", () => { it("accepts colon-separated MAC addresses", () => { @@ -49,3 +62,55 @@ describe("buildMagicPacket", () => { expect(colon.equals(hyphen)).toBe(true); }); }); + +describe("sendWakeOnLan", () => { + let mockSocket: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockSocket = (dgram.createSocket as ReturnType)(); + (mockSocket.bind as ReturnType).mockImplementation( + (cb: () => void) => cb(), + ); + (mockSocket.send as ReturnType).mockImplementation( + ( + _buf: unknown, + _off: unknown, + _len: unknown, + _port: unknown, + _addr: unknown, + cb: (err: null) => void, + ) => cb(null), + ); + }); + + it("rejects on invalid MAC address", async () => { + await expect(sendWakeOnLan("not-a-mac")).rejects.toThrow( + "Invalid MAC address", + ); + }); + + it("sends to 255.255.255.255 by default", async () => { + await sendWakeOnLan("aa:bb:cc:dd:ee:ff"); + expect(mockSocket.send).toHaveBeenCalledWith( + expect.any(Buffer), + 0, + 102, + 9, + "255.255.255.255", + expect.any(Function), + ); + }); + + it("sends to a custom broadcast address when provided", async () => { + await sendWakeOnLan("aa:bb:cc:dd:ee:ff", "192.168.1.255"); + expect(mockSocket.send).toHaveBeenCalledWith( + expect.any(Buffer), + 0, + 102, + 9, + "192.168.1.255", + expect.any(Function), + ); + }); +}); diff --git a/src/backend/utils/wake-on-lan.ts b/src/backend/utils/wake-on-lan.ts index 69b98659d..e02b96ea2 100644 --- a/src/backend/utils/wake-on-lan.ts +++ b/src/backend/utils/wake-on-lan.ts @@ -20,7 +20,10 @@ export function isValidMac(mac: string): boolean { return MAC_REGEX.test(mac); } -export function sendWakeOnLan(mac: string): Promise { +export function sendWakeOnLan( + mac: string, + broadcastAddress = "255.255.255.255", +): Promise { return new Promise((resolve, reject) => { if (!isValidMac(mac)) { return reject(new Error("Invalid MAC address")); @@ -36,7 +39,7 @@ export function sendWakeOnLan(mac: string): Promise { socket.bind(() => { socket.setBroadcast(true); - socket.send(packet, 0, packet.length, 9, "255.255.255.255", (err) => { + socket.send(packet, 0, packet.length, 9, broadcastAddress, (err) => { socket.close(); if (err) reject(err); else resolve(); diff --git a/src/main.tsx b/src/main.tsx index 329eaa06f..7258d9c3e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -70,6 +70,7 @@ function FullscreenApp() { const view = searchParams.get("view"); const hostId = searchParams.get("hostId"); const tmuxSession = searchParams.get("tmuxSession"); + const path = searchParams.get("path"); switch (view) { case "terminal": @@ -80,7 +81,12 @@ function FullscreenApp() { /> ); case "file-manager": - return ; + return ( + + ); case "tunnel": return ; case "host-metrics": @@ -168,6 +174,11 @@ function App() { }, 450); } + function handleChangeServer() { + localStorage.setItem("termix_show_server_config", "true"); + handleLogout(); + } + const showApp = phase === "idle-app" || phase === "fading-in" || phase === "fading-out"; const showAuth = @@ -211,7 +222,11 @@ function App() { }} > - +
)} diff --git a/src/types/index.ts b/src/types/index.ts index bdeae2178..1b741f2ff 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -35,6 +35,7 @@ export interface OIDCProviderConfig { allowed_users?: string; admin_group?: string; group_claim?: string; + ca_cert?: string; } export interface LDAPProviderConfig { @@ -127,6 +128,7 @@ export interface Host { enableCommandHistory: boolean; enableTunnel: boolean; enableFileManager: boolean; + scpLegacy?: boolean; enableDocker: boolean; enableProxmox: boolean; enableTmuxMonitor: boolean; @@ -152,6 +154,7 @@ export interface Host { socks5ProxyChain?: ProxyNode[]; macAddress?: string; + wolBroadcastAddress?: string; portKnockSequence?: Array<{ port: number; protocol?: "tcp" | "udp"; @@ -172,11 +175,13 @@ export interface Host { rdpPort?: number; vncPort?: number; telnetPort?: number; + rdpCredentialId?: number | null; rdpUser?: string; rdpPassword?: string; rdpDomain?: string; rdpSecurity?: string; rdpIgnoreCert?: boolean; + vncCredentialId?: number | null; vncPassword?: string; vncUser?: string; telnetUser?: string; @@ -237,6 +242,7 @@ export interface HostData { enableCommandHistory?: boolean; enableTunnel?: boolean; enableFileManager?: boolean; + scpLegacy?: boolean; enableDocker?: boolean; enableProxmox?: boolean; enableTmuxMonitor?: boolean; @@ -263,6 +269,7 @@ export interface HostData { socks5ProxyChain?: ProxyNode[]; macAddress?: string; + wolBroadcastAddress?: string; portKnockSequence?: Array<{ port: number; protocol?: "tcp" | "udp"; @@ -284,11 +291,13 @@ export interface HostData { rdpPort?: number; vncPort?: number; telnetPort?: number; + rdpCredentialId?: number | null; rdpUser?: string; rdpPassword?: string; rdpDomain?: string; rdpSecurity?: string; rdpIgnoreCert?: boolean; + vncCredentialId?: number | null; vncPassword?: string; vncUser?: string; telnetUser?: string; @@ -611,6 +620,10 @@ export interface TerminalConfig { urls: boolean; numbers: boolean; }; + backgroundImage?: string; + backgroundImageOpacity?: number; + allowLegacyAlgorithms?: boolean; + linkClickBehavior?: "confirm" | "direct"; } // ============================================================================ diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 236f8f3d4..97478c512 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -31,6 +31,7 @@ export type Host = { keyType?: string; notes?: string; macAddress?: string; + wolBroadcastAddress?: string; pin?: boolean; enableTerminal: boolean; @@ -60,6 +61,7 @@ export type Host = { keepaliveCountMax?: number; environmentVariables: { key: string; value: string }[]; startupSnippetId?: number | null; + linkClickBehavior?: "confirm" | "direct"; }; useSocks5?: boolean; @@ -95,6 +97,7 @@ export type Host = { }[]; enableFileManager: boolean; + scpLegacy?: boolean; defaultPath?: string; enableDocker: boolean; @@ -102,6 +105,7 @@ export type Host = { enableTmuxMonitor: boolean; proxmoxConfig?: { defaultCredentialId: number | null; + defaultAuthType?: string; windowsPatterns: string; dockerPatterns: string; preferredPrefixes: string; @@ -128,12 +132,14 @@ export type Host = { vncPort: number; telnetPort: number; + rdpCredentialId?: string; rdpUser?: string; rdpPassword?: string; domain?: string; security?: string; ignoreCert?: boolean; + vncCredentialId?: string; vncPassword?: string; vncUser?: string; @@ -208,9 +214,11 @@ export type Tab = { instanceId: string; type: TabType; label: string; + customLabel?: string; host?: Host; openedAt: number; restoredSessionId?: string | null; + initialFilePath?: string; terminalRef?: import("react").RefObject<{ sendInput?: (data: string) => void; reconnect?: () => void; @@ -244,7 +252,8 @@ export type DashboardCardId = | "quick_actions" | "host_status" | "recent_activity" - | "network_graph"; + | "network_graph" + | "service_links"; export type DashboardCardConfig = { id: DashboardCardId; @@ -283,9 +292,11 @@ export type AdminSection = | "users" | "sessions" | "roles" + | "host-defaults" | "database" | "api-keys" - | "audit-log"; + | "audit-log" + | "ssl"; export type AccentColorId = string; export type ThemeId = | "dark" @@ -317,6 +328,7 @@ export type Snippet = { content: string; folder: string | null; order: number; + hostIds?: number[]; }; export const FOLDER_ICONS = [ diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index dfe86556a..aca73af34 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -161,9 +161,11 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils"; export function AppShell({ username, onLogout, + onChangeServer, }: { username: string; onLogout: () => void; + onChangeServer?: () => void; }) { const { t, i18n } = useTranslation(); const { setTheme } = useTheme(); @@ -204,7 +206,6 @@ export function AppShell({ const [sidebarOpen, setSidebarOpen] = useState(true); const [railView, setRailView] = useState("hosts"); - const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("termix_sidebarWidth"); return saved ? parseInt(saved, 10) : 291; @@ -314,6 +315,84 @@ export function AppShell({ return () => window.removeEventListener("keydown", handleKeyDown); }, [commandPaletteShortcutEnabled]); + // Split-screen and tab navigation hotkeys + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+Shift+\ — toggle 2-way split (side by side) + if (e.ctrlKey && e.shiftKey && !e.altKey && e.key === "\\") { + e.preventDefault(); + setSplitMode((prev) => { + if (prev !== "none") { + setPaneTabIds(Array(6).fill(null)); + return "none"; + } + return "2-way"; + }); + return; + } + + // Ctrl+Shift+- — toggle 3-way-horizontal split (top/bottom) + if (e.ctrlKey && e.shiftKey && !e.altKey && e.key === "-") { + e.preventDefault(); + setSplitMode((prev) => { + if (prev !== "none") { + setPaneTabIds(Array(6).fill(null)); + return "none"; + } + return "3-way-horizontal"; + }); + return; + } + + // Alt+Arrow — navigate between panes in split mode + if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { + if ( + e.key === "ArrowLeft" || + e.key === "ArrowRight" || + e.key === "ArrowUp" || + e.key === "ArrowDown" + ) { + setSplitMode((currentMode) => { + if (currentMode === "none") return currentMode; + const count = PANE_COUNTS[currentMode]; + if (count < 2) return currentMode; + setFocusedPaneIndex((prev) => { + const current = prev ?? 0; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + return (current + 1) % count; + } else { + e.preventDefault(); + return (current - 1 + count) % count; + } + }); + return currentMode; + }); + return; + } + } + + // Ctrl+Tab / Ctrl+Shift+Tab — cycle through open tabs + if (e.ctrlKey && !e.altKey && !e.metaKey && e.key === "Tab") { + e.preventDefault(); + setTabs((currentTabs) => { + if (currentTabs.length < 2) return currentTabs; + setActiveTabId((currentId) => { + const idx = currentTabs.findIndex((t) => t.id === currentId); + const next = e.shiftKey + ? (idx - 1 + currentTabs.length) % currentTabs.length + : (idx + 1) % currentTabs.length; + return currentTabs[next].id; + }); + return currentTabs; + }); + return; + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + useEffect(() => { const handler = () => { const v = localStorage.getItem("commandPaletteShortcutEnabled"); @@ -623,11 +702,17 @@ export function AppShell({ const restoredSessionId = liveSession?.sessionId ?? saved.backendSessionId ?? null; + const isCustomLabel = + host && + saved.label !== host.name && + !/^.+ \(\d+\)$/.test(saved.label); + restoredTabs.push({ id: tabId, instanceId: saved.id, type: saved.tabType as TabType, label: saved.label, + customLabel: isCustomLabel ? saved.label : undefined, host, openedAt: new Date(saved.createdAt).getTime(), restoredSessionId, @@ -694,7 +779,12 @@ export function AppShell({ const openTab = useCallback(function openTab( host: Host, type: TabType, - restore?: { instanceId: string; restoredSessionId: string | null }, + restore?: { + instanceId: string; + restoredSessionId: string | null; + savedLabel?: string; + initialFilePath?: string; + }, ) { const tabId = `${host.name}-${type}-${Date.now()}`; const instanceId = @@ -707,7 +797,34 @@ export function AppShell({ if (ref) terminalRefs.current.set(tabId, ref); let finalLabel = host.name; + const savedLabel = restore?.savedLabel; + const initialFilePath = restore?.initialFilePath; + // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label + const isCustomLabel = + savedLabel != null && + savedLabel !== host.name && + !/^.+ \(\d+\)$/.test(savedLabel); + setTabs((prev) => { + if (isCustomLabel && savedLabel) { + finalLabel = savedLabel; + return [ + ...prev, + { + id: tabId, + instanceId, + type, + label: finalLabel, + customLabel: finalLabel, + host, + openedAt, + terminalRef: ref, + restoredSessionId: restore?.restoredSessionId ?? null, + initialFilePath, + }, + ]; + } + const same = prev.filter( (t) => t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, @@ -734,6 +851,7 @@ export function AppShell({ openedAt, terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, + initialFilePath, }, ]; }); @@ -789,6 +907,9 @@ export function AppShell({ ), 0, ); + } else if (pendingEvent === "host-manager:show-credentials") { + setSidebarOpen(true); + setRailView("credentials"); } else { setSidebarOpen(true); setRailView("hosts"); @@ -919,6 +1040,18 @@ export function AppShell({ doCloseTab(id); } + function renameTab(tabId: string, newLabel: string) { + setTabs((prev) => + prev.map((t) => + t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t, + ), + ); + const tab = tabs.find((t) => t.id === tabId); + if (tab?.instanceId) { + patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {}); + } + } + function splitTabQuick(tabId: string, mode: SplitMode) { setSplitMode(mode); setPaneTabIds(() => { @@ -1182,6 +1315,7 @@ export function AppShell({ openTab(host, record.tabType as TabType, { instanceId: record.id, restoredSessionId: effectiveSessionId, + savedLabel: record.label, }); } else { openSingletonTab(record.tabType as TabType); @@ -1193,6 +1327,8 @@ export function AppShell({ prev.filter((r) => r.id !== recordId), ); }} + onRenameTab={renameTab} + onReorderTabs={setTabs} />
)} @@ -1208,6 +1344,7 @@ export function AppShell({ @@ -1264,8 +1401,6 @@ export function AppShell({ splitMode={splitMode} username={username} isAdmin={isAdmin} - profileDropdownOpen={profileDropdownOpen} - onProfileDropdownChange={setProfileDropdownOpen} onRailClick={handleRailClick} onOpenTab={openSingletonTab} onLogout={onLogout} @@ -1334,6 +1469,7 @@ export function AppShell({ onSplitTab={splitTabQuick} onAddToSplit={addTabToSplit} onRemoveFromSplit={removeTabFromSplit} + onRenameTab={renameTab} />
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */} @@ -1388,6 +1524,17 @@ export function AppShell({ openTab, closeTab, inPane || activeInline, + (host, filePath) => + openTab(host, "files", { + instanceId: + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + restoredSessionId: null, + initialFilePath: filePath, + }), + (host, path) => openTab(host, "files"), + renameTab, ), tabNode, tab.id, diff --git a/src/ui/api/acme-ssl-api.ts b/src/ui/api/acme-ssl-api.ts new file mode 100644 index 000000000..64bb286af --- /dev/null +++ b/src/ui/api/acme-ssl-api.ts @@ -0,0 +1,47 @@ +import { authApi, handleApiError } from "@/main-axios"; + +export type AcmeChallengeType = "http-webroot" | "dns-cloudflare"; + +export type AcmeSettings = { + enabled: boolean; + domain: string; + email: string; + challengeType: AcmeChallengeType; + cloudflareToken: string; + lastIssuedAt: string | null; + certStatus: "none" | "valid" | "expiring" | "expired"; + certExpiresAt: string | null; +}; + +export async function getAcmeSslSettings(): Promise { + try { + const response = await authApi.get("/users/acme-ssl-settings"); + return response.data; + } catch (error) { + handleApiError(error, "fetch ACME SSL settings"); + } +} + +export async function updateAcmeSslSettings( + settings: Partial< + Omit + >, +): Promise { + try { + const response = await authApi.patch("/users/acme-ssl-settings", settings); + return response.data; + } catch (error) { + handleApiError(error, "update ACME SSL settings"); + } +} + +export async function requestAcmeCertificate(): Promise< + AcmeSettings & { success: boolean } +> { + try { + const response = await authApi.post("/users/acme-ssl-request", {}); + return response.data; + } catch (error) { + handleApiError(error, "request ACME certificate"); + } +} diff --git a/src/ui/api/dashboard-api.ts b/src/ui/api/dashboard-api.ts index 584bda35e..281eda407 100644 --- a/src/ui/api/dashboard-api.ts +++ b/src/ui/api/dashboard-api.ts @@ -81,3 +81,56 @@ export async function resetRecentActivity(): Promise<{ message: string }> { throw handleApiError(error, "reset recent activity"); } } + +export interface ServiceLink { + id: number; + userId: string; + label: string; + url: string; + order: number; + createdAt: string; +} + +export async function getServiceLinks(): Promise { + try { + const response = await dashboardApi.get("/service-links"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch service links"); + } +} + +export async function createServiceLink( + label: string, + url: string, +): Promise { + try { + const response = await dashboardApi.post("/service-links", { label, url }); + return response.data; + } catch (error) { + throw handleApiError(error, "create service link"); + } +} + +export async function deleteServiceLink( + id: number, +): Promise<{ message: string }> { + try { + const response = await dashboardApi.delete(`/service-links/${id}`); + return response.data; + } catch (error) { + throw handleApiError(error, "delete service link"); + } +} + +export async function updateServiceLink( + id: number, + updates: { label?: string; url?: string }, +): Promise { + try { + const response = await dashboardApi.put(`/service-links/${id}`, updates); + return response.data; + } catch (error) { + throw handleApiError(error, "update service link"); + } +} diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index ad2f62dba..880bf9936 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -94,6 +94,8 @@ export interface UserPreferences { disableUpdateCheck?: boolean | null; confirmTabClose?: boolean | null; hiddenRailTabs?: string | null; + compactHostView?: boolean | null; + statusColorScheme?: string | null; } export async function getUserPreferences(): Promise { diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index 79fd12d38..4eeed208f 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -146,3 +146,43 @@ export async function updateGuacamoleSettings(settings: { } // ============================================================================ +// HOST DEFAULTS SETTINGS +// ============================================================================ + +export type HostDefaults = { + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + credentialId?: number | null; + metricsEnabled?: boolean; + statusCheckEnabled?: boolean; + fontSize?: number; + fontFamily?: string; + theme?: string; + cursorStyle?: string; + cursorBlink?: boolean; + enableSessionLogging?: boolean; + enableCommandHistory?: boolean; +}; + +export async function getHostDefaults(): Promise { + try { + const response = await authApi.get("/users/host-defaults"); + return response.data; + } catch (error) { + handleApiError(error, "fetch host defaults"); + } +} + +export async function updateHostDefaults( + defaults: HostDefaults, +): Promise { + try { + const response = await authApi.patch("/users/host-defaults", defaults); + return response.data; + } catch (error) { + handleApiError(error, "update host defaults"); + } +} diff --git a/src/ui/api/snippets-api.ts b/src/ui/api/snippets-api.ts index 5c18e5e76..92ee345dd 100644 --- a/src/ui/api/snippets-api.ts +++ b/src/ui/api/snippets-api.ts @@ -181,3 +181,52 @@ export async function reorderSnippets( throw handleApiError(error, "reorder snippets"); } } + +export interface SnippetExportData { + snippets: Array<{ + name: string; + content: string; + description?: string | null; + folder?: string | null; + order?: number; + hostFilter?: string | null; + }>; + folders: Array<{ + name: string; + color?: string | null; + icon?: string | null; + }>; +} + +export async function exportSnippets(): Promise { + try { + const response = await authApi.get("/snippets/export"); + return response.data; + } catch (error) { + throw handleApiError(error, "export snippets"); + } +} + +export async function importSnippets( + data: SnippetExportData, + overwrite: boolean, +): Promise<{ + success: boolean; + snippetsImported: number; + snippetsSkipped: number; + snippetsUpdated: number; + foldersImported: number; + foldersSkipped: number; + failed: number; + errors: string[]; +}> { + try { + const response = await authApi.post("/snippets/bulk-import", { + ...data, + overwrite, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "import snippets"); + } +} diff --git a/src/ui/api/ssh-file-operations-api.ts b/src/ui/api/ssh-file-operations-api.ts index 3206c2282..c91636ed3 100644 --- a/src/ui/api/ssh-file-operations-api.ts +++ b/src/ui/api/ssh-file-operations-api.ts @@ -350,14 +350,18 @@ export async function uploadSSHFile( userId?: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/uploadFile", { - sessionId, - path, - fileName, - content, - hostId, - userId, - }); + const response = await fileManagerApi.post( + "/ssh/uploadFile", + { + sessionId, + path, + fileName, + content, + hostId, + userId, + }, + { timeout: 0 }, + ); return response.data; } catch (error) { handleApiError(error, "upload SSH file"); @@ -371,12 +375,16 @@ export async function downloadSSHFile( userId?: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/downloadFile", { - sessionId, - path: filePath, - hostId, - userId, - }); + const response = await fileManagerApi.post( + "/ssh/downloadFile", + { + sessionId, + path: filePath, + hostId, + userId, + }, + { timeout: 0 }, + ); return response.data; } catch (error) { handleApiError(error, "download SSH file"); @@ -390,7 +398,7 @@ export async function downloadSSHFileStream( const response = await fileManagerApi.post( "/ssh/downloadFileStream", { sessionId, path: filePath }, - { responseType: "blob" }, + { responseType: "blob", timeout: 0 }, ); const blob = response.data as Blob; const fileName = filePath.split("/").pop() || "download"; diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 59f9d9b62..303bf4910 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -107,6 +107,28 @@ export async function bulkImportSSHHosts( } } +export async function importSSHConfigHosts( + content: string, + overwrite = false, +): Promise<{ + message: string; + success: number; + updated: number; + skipped: number; + failed: number; + errors: string[]; +}> { + try { + const response = await sshHostApi.post("/ssh-config-import", { + content, + overwrite, + }); + return response.data; + } catch (error) { + handleApiError(error, "import SSH config hosts"); + } +} + export async function discoverProxmoxGuests( hostId: number, ): Promise { diff --git a/src/ui/api/user-management-api.ts b/src/ui/api/user-management-api.ts index ef7d7c5e2..1fe4c6e36 100644 --- a/src/ui/api/user-management-api.ts +++ b/src/ui/api/user-management-api.ts @@ -196,6 +196,30 @@ export async function updateOidcAutoProvision( } } +export async function getOidcSilentLoginDefault(): Promise<{ + enabled: boolean; +}> { + try { + const response = await authApi.get("/users/oidc-silent-login-default"); + return response.data; + } catch (error) { + handleApiError(error, "get OIDC silent login default"); + } +} + +export async function updateOidcSilentLoginDefault( + enabled: boolean, +): Promise<{ enabled: boolean }> { + try { + const response = await authApi.patch("/users/oidc-silent-login-default", { + enabled, + }); + return response.data; + } catch (error) { + handleApiError(error, "update OIDC silent login default"); + } +} + export async function updatePasswordLoginAllowed( allowed: boolean, ): Promise<{ allowed: boolean }> { diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 733638462..8bb58e199 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -34,6 +34,7 @@ import { isElectron, getEmbeddedServerStatus, getCurrentToken, + getOidcSilentLoginDefault, } from "@/main-axios"; import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; import type { SSOProviderPublic } from "@/types/index"; @@ -256,6 +257,9 @@ export function Auth({ onLogin }: AuthProps) { const [ldapUsername, setLdapUsername] = useState(""); const [ldapPassword, setLdapPassword] = useState(""); const silentSigninHandledRef = useRef(false); + const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false); + const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] = + useState(false); const [firstUser, setFirstUser] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbHealthChecking, setDbHealthChecking] = useState(true); @@ -288,6 +292,10 @@ export function Auth({ onLogin }: AuthProps) { .then((providers) => setSsoProviders(providers || [])) .catch(() => setSsoProviders([])) .finally(() => setSsoProvidersLoaded(true)); + getOidcSilentLoginDefault() + .then((res) => setOidcSilentLoginDefault(res.enabled)) + .catch(() => {}) + .finally(() => setOidcSilentLoginDefaultLoaded(true)); }, []); useEffect(() => { @@ -312,6 +320,18 @@ export function Auth({ onLogin }: AuthProps) { return; } if (isElectron()) { + const forceShow = localStorage.getItem("termix_show_server_config"); + if (forceShow === "true") { + localStorage.removeItem("termix_show_server_config"); + try { + const config = await getServerConfig(); + setCurrentServerUrl(config?.serverUrl || ""); + } catch { + // ignore + } + setShowServerConfig(true); + return; + } try { const [config, status] = await Promise.all([ getServerConfig(), @@ -857,14 +877,19 @@ export function Auth({ onLogin }: AuthProps) { useEffect(() => { if (!ssoProvidersLoaded || silentSigninHandledRef.current) return; - if (!shouldTriggerSilentSignin(window.location.search)) return; + if (!oidcSilentLoginDefaultLoaded) return; - const nextSearch = removeSilentSigninFromSearch(window.location.search); - window.history.replaceState( - {}, - document.title, - `${window.location.pathname}${nextSearch}${window.location.hash}`, - ); + const urlTriggered = shouldTriggerSilentSignin(window.location.search); + if (!urlTriggered && !oidcSilentLoginDefault) return; + + if (urlTriggered) { + const nextSearch = removeSilentSigninFromSearch(window.location.search); + window.history.replaceState( + {}, + document.title, + `${window.location.pathname}${nextSearch}${window.location.hash}`, + ); + } silentSigninHandledRef.current = true; @@ -876,8 +901,17 @@ export function Auth({ onLogin }: AuthProps) { return; } - toast.info(t("errors.silentSigninOidcUnavailable")); - }, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]); + if (urlTriggered) { + toast.info(t("errors.silentSigninOidcUnavailable")); + } + }, [ + handleOIDCLogin, + ssoProvidersLoaded, + ssoProviders, + t, + oidcSilentLoginDefault, + oidcSilentLoginDefaultLoaded, + ]); // Electron server config / webview auth success screens if (isElectron() && !isInElectronWebView()) { diff --git a/src/ui/auth/ElectronServerConfig.tsx b/src/ui/auth/ElectronServerConfig.tsx index 65137c3c3..eef3ff1aa 100644 --- a/src/ui/auth/ElectronServerConfig.tsx +++ b/src/ui/auth/ElectronServerConfig.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Button } from "@/components/button.tsx"; import { Input } from "@/components/input.tsx"; import { Label } from "@/components/label.tsx"; @@ -12,7 +12,34 @@ import { setEmbeddedMode, type ServerConfig, } from "@/main-axios.ts"; -import { Server, Monitor, Loader2 } from "lucide-react"; +import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react"; + +const SAVED_URLS_KEY = "termix_saved_server_urls"; +const MAX_SAVED_URLS = 5; + +function getSavedUrls(): string[] { + try { + const raw = localStorage.getItem(SAVED_URLS_KEY); + if (!raw) return []; + return JSON.parse(raw); + } catch { + return []; + } +} + +function addSavedUrl(url: string) { + const urls = getSavedUrls().filter((u) => u !== url); + urls.unshift(url); + localStorage.setItem( + SAVED_URLS_KEY, + JSON.stringify(urls.slice(0, MAX_SAVED_URLS)), + ); +} + +function removeSavedUrl(url: string) { + const urls = getSavedUrls().filter((u) => u !== url); + localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls)); +} interface ServerConfigProps { onServerConfigured: (serverUrl: string) => void; @@ -36,12 +63,31 @@ export function ElectronServerConfig({ const [embeddedAvailable, setEmbeddedAvailable] = useState( null, ); + const [savedUrls, setSavedUrls] = useState([]); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); useEffect(() => { loadServerConfig(); checkEmbeddedBackend(); + setSavedUrls(getSavedUrls()); }, []); + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setDropdownOpen(false); + } + } + if (dropdownOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [dropdownOpen]); + const loadServerConfig = async () => { try { const config = await getServerConfig(); @@ -151,6 +197,8 @@ export function ElectronServerConfig({ const success = await saveServerConfig(config); if (success) { + addSavedUrl(normalizedUrl); + setSavedUrls(getSavedUrls()); onServerConfigured(normalizedUrl); } else { setError(t("serverConfig.saveFailed")); @@ -220,14 +268,70 @@ export function ElectronServerConfig({
- handleUrlChange(e.target.value)} - disabled={loading || embeddedLoading} - /> +
+ handleUrlChange(e.target.value)} + disabled={loading || embeddedLoading} + className={savedUrls.length > 0 ? "pr-9" : ""} + onFocus={() => { + if (savedUrls.length > 0) setDropdownOpen(true); + }} + /> + {savedUrls.length > 0 && ( + + )} + {dropdownOpen && savedUrls.length > 0 && ( +
+

+ {t("serverConfig.savedServers")} +

+ {savedUrls.map((url) => ( +
+ + +
+ ))} +
+ )} +
{serverUrl.trim().startsWith("https://") && ( diff --git a/src/ui/auth/LoginPage.tsx b/src/ui/auth/LoginPage.tsx index 1da26040c..fa0d829bc 100644 --- a/src/ui/auth/LoginPage.tsx +++ b/src/ui/auth/LoginPage.tsx @@ -29,6 +29,7 @@ import { isElectron, getEmbeddedServerStatus, getCurrentToken, + getOidcSilentLoginDefault, } from "@/main-axios"; import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; import type { SSOProviderPublic } from "@/types/index"; @@ -129,6 +130,9 @@ export function Auth({ const [ldapPassword, setLdapPassword] = useState(""); const [ldapLoading, setLdapLoading] = useState(false); const silentSigninHandledRef = useRef(false); + const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false); + const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] = + useState(false); const [resetStep, setResetStep] = useState< "initiate" | "verify" | "newPassword" @@ -258,6 +262,17 @@ export function Auth({ }); }, []); + useEffect(() => { + getOidcSilentLoginDefault() + .then((res) => { + setOidcSilentLoginDefault(res.enabled); + }) + .catch(() => {}) + .finally(() => { + setOidcSilentLoginDefaultLoaded(true); + }); + }, []); + useEffect(() => { if (showServerConfig) { return; @@ -726,14 +741,19 @@ export function Auth({ useEffect(() => { if (!ssoProvidersLoaded || silentSigninHandledRef.current) return; - if (!shouldTriggerSilentSignin(window.location.search)) return; + if (!oidcSilentLoginDefaultLoaded) return; - const nextSearch = removeSilentSigninFromSearch(window.location.search); - window.history.replaceState( - {}, - document.title, - `${window.location.pathname}${nextSearch}${window.location.hash}`, - ); + const urlTriggered = shouldTriggerSilentSignin(window.location.search); + if (!urlTriggered && !oidcSilentLoginDefault) return; + + if (urlTriggered) { + const nextSearch = removeSilentSigninFromSearch(window.location.search); + window.history.replaceState( + {}, + document.title, + `${window.location.pathname}${nextSearch}${window.location.hash}`, + ); + } silentSigninHandledRef.current = true; const oidcProvider = ssoProviders.find( @@ -750,8 +770,17 @@ export function Auth({ return; } - toast.info(t("errors.silentSigninOidcUnavailable")); - }, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]); + if (urlTriggered) { + toast.info(t("errors.silentSigninOidcUnavailable")); + } + }, [ + handleOIDCLogin, + ssoProvidersLoaded, + ssoProviders, + t, + oidcSilentLoginDefault, + oidcSilentLoginDefaultLoaded, + ]); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); diff --git a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx index f302d70a8..f34e08126 100644 --- a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx +++ b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx @@ -34,6 +34,8 @@ interface ProxmoxDiscoverDialogProps { preselectedHostId?: number; /** Credential to use for imported hosts */ defaultCredentialId?: number | null; + /** Auth type to use for imported hosts */ + defaultAuthType?: string; /** Username from the default credential */ defaultUsername?: string; } @@ -45,6 +47,7 @@ export function ProxmoxDiscoverDialog({ onHostsChanged, preselectedHostId, defaultCredentialId, + defaultAuthType, defaultUsername, }: ProxmoxDiscoverDialogProps) { const { t } = useTranslation(); @@ -115,6 +118,8 @@ export function ProxmoxDiscoverDialog({ try { // Prefer explicitly configured credential, then fall back to the host's own credential const credId = defaultCredentialId ?? discoveredCredentialId; + const resolvedAuthType = + defaultAuthType ?? (credId != null ? "credential" : "password"); const toImport = guests .filter((g) => selected.has(g.vmid)) @@ -124,13 +129,13 @@ export function ProxmoxDiscoverDialog({ port: g.connectionType === "rdp" ? 3389 : 22, username: defaultUsername ?? "root", folder: importFolder, - ...(credId != null + authType: resolvedAuthType, + ...(resolvedAuthType === "credential" && credId != null ? { - authType: "credential" as const, credentialId: credId, overrideCredentialUsername: true, } - : { authType: "password" as const }), + : {}), enableTerminal: g.connectionType !== "rdp", enableFileManager: g.connectionType !== "rdp", enableTunnel: g.connectionType !== "rdp", diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index ab214bcb8..48a708259 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -6,10 +6,12 @@ import { Separator } from "@/components/separator"; import { Activity, Database, + ExternalLink, GripHorizontal, GripVertical, KeyRound, LayoutDashboard, + Link, MessagesSquare, Network, Plus, @@ -37,10 +39,21 @@ import { registerMetricsViewer, sendMetricsHeartbeat, getUserInfo, + getServiceLinks, + createServiceLink, + deleteServiceLink, +} from "@/main-axios"; +import type { + RecentActivityItem, + SSHHostWithStatus, + ServiceLink, } from "@/main-axios"; -import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios"; import { useTranslation } from "react-i18next"; import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard"; +import { + useStatusColorScheme, + getStatusClasses, +} from "@/hooks/use-status-color-scheme"; function sshHostToHost(h: SSHHostWithStatus): Host { return { @@ -205,35 +218,48 @@ function CountersBarCard({ hosts, credentialCount, activeTunnelCount, + onOpenSingletonTab, }: { hosts: Host[]; credentialCount: number; activeTunnelCount: number; + onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; }) { const { t } = useTranslation(); return ( -
+
-
+ +
-
+ +
+
); } @@ -365,16 +391,48 @@ function QuickActionsCard({ ); } +function MetricBar({ label, value }: { label: string; value: number }) { + const color = + value >= 90 + ? "bg-red-500" + : value >= 70 + ? "bg-yellow-500" + : "bg-accent-brand"; + const textColor = + value >= 90 + ? "text-red-400" + : value >= 70 + ? "text-yellow-400" + : "text-accent-brand"; + return ( +
+
+ {label} + + {value.toFixed(0)}% + +
+
+
+
+
+ ); +} + function HostStatusCard({ hosts, hostMetrics, onOpenTab, }: { hosts: Host[]; - hostMetrics: Map; + hostMetrics: Map< + string, + { cpu: number | null; ram: number | null; disk: number | null } + >; onOpenTab: (host: Host, type: TabType) => void; }) { const { t } = useTranslation(); + const statusScheme = useStatusColorScheme(); const online = hosts.filter((h) => h.online).length; return ( @@ -399,19 +457,23 @@ function HostStatusCard({ const metrics = hostMetrics.get(host.id); const cpu = metrics?.cpu ?? null; const ram = metrics?.ram ?? null; - const hasMetrics = cpu !== null || ram !== null; + const disk = metrics?.disk ?? null; + const hasMetrics = cpu !== null || ram !== null || disk !== null; return (
onOpenTab(host, "host-metrics")} - className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer" + className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row" >
- {host.name} +
+ {host.name} + +
{host.ip} @@ -421,40 +483,13 @@ function HostStatusCard({ {host.online && hasMetrics ? (
{cpu !== null && ( -
-
- - {t("dashboard.cpu")} - - - {cpu.toFixed(0)}% - -
-
-
-
-
+ )} {ram !== null && ( -
-
- - {t("dashboard.ram")} - - - {ram.toFixed(0)}% - -
-
-
-
-
+ + )} + {disk !== null && ( + )}
) : ( @@ -465,10 +500,13 @@ function HostStatusCard({ + + — +
)} {host.online ? t("dashboardTab.online") @@ -495,6 +533,7 @@ function RecentActivityCard({ onClear: () => void; }) { const { t } = useTranslation(); + const statusScheme = useStatusColorScheme(); const typeIcon: Record = { terminal: , file_manager: , @@ -570,7 +609,7 @@ function RecentActivityCard({ >
@@ -593,6 +632,124 @@ function RecentActivityCard({ ); } +function ServiceLinksCard({ + links, + onAdd, + onDelete, +}: { + links: ServiceLink[]; + onAdd: (label: string, url: string) => Promise; + onDelete: (id: number) => Promise; +}) { + const { t } = useTranslation(); + const [label, setLabel] = useState(""); + const [url, setUrl] = useState(""); + const [urlError, setUrlError] = useState(false); + const [adding, setAdding] = useState(false); + + const handleAdd = async () => { + let valid = true; + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + valid = false; + } + } catch { + valid = false; + } + if (!valid) { + setUrlError(true); + return; + } + setUrlError(false); + setAdding(true); + try { + await onAdd(label.trim(), url.trim()); + setLabel(""); + setUrl(""); + } finally { + setAdding(false); + } + }; + + return ( + +
+ + + {t("dashboardTab.serviceLinksTitle")} + +
+
+ {links.length === 0 && ( +
+ {t("dashboardTab.serviceLinksEmpty")} +
+ )} + {links.map((link) => ( +
+ + + + {link.label} + + + {link.url} + + + +
+ ))} +
+
+ setLabel(e.target.value)} + placeholder={t("dashboardTab.serviceLinksLabelPlaceholder")} + className="flex-1 min-w-0 text-xs bg-transparent border border-border px-2 py-1 focus:outline-none focus:border-accent-brand/60" + /> + { + setUrl(e.target.value); + setUrlError(false); + }} + placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")} + className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`} + /> + +
+ {urlError && ( +
+ {t("dashboardTab.serviceLinksInvalidUrl")} +
+ )} +
+ ); +} + // ─── CardItem ───────────────────────────────────────────────────────────────── function CardItem({ @@ -617,6 +774,9 @@ function CardItem({ activity, onClearActivity, isAdmin, + serviceLinks, + onAddServiceLink, + onDeleteServiceLink, }: { slot: CardSlot; editMode: boolean; @@ -629,7 +789,10 @@ function CardItem({ onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; - hostMetrics: Map; + hostMetrics: Map< + string, + { cpu: number | null; ram: number | null; disk: number | null } + >; uptimeFormatted: string; versionText: string; versionStatus: "up_to_date" | "requires_update" | "beta"; @@ -639,6 +802,9 @@ function CardItem({ activity: RecentActivityItem[]; onClearActivity: () => void; isAdmin: boolean; + serviceLinks: ServiceLink[]; + onAddServiceLink: (label: string, url: string) => Promise; + onDeleteServiceLink: (id: number) => Promise; }) { const cardRef = useRef(null); @@ -704,6 +870,7 @@ function CardItem({ hosts={hosts} credentialCount={credentialCount} activeTunnelCount={activeTunnelCount} + onOpenSingletonTab={onOpenSingletonTab} /> )} {slot.id === "quick_actions" && ( @@ -735,6 +902,13 @@ function CardItem({ onOpenInNewTab={() => onOpenSingletonTab("network_graph")} /> )} + {slot.id === "service_links" && ( + + )}
{editMode && !isFlex && (
void; onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; - hostMetrics: Map; + hostMetrics: Map< + string, + { cpu: number | null; ram: number | null; disk: number | null } + >; uptimeFormatted: string; versionText: string; versionStatus: "up_to_date" | "requires_update" | "beta"; @@ -842,6 +1019,9 @@ type PanelColumnProps = { onClearActivity: () => void; cardLabels: Record; isAdmin: boolean; + serviceLinks: ServiceLink[]; + onAddServiceLink: (label: string, url: string) => Promise; + onDeleteServiceLink: (id: number) => Promise; }; function PanelColumn({ @@ -869,6 +1049,9 @@ function PanelColumn({ onClearActivity, cardLabels, isAdmin, + serviceLinks, + onAddServiceLink, + onDeleteServiceLink, }: PanelColumnProps) { const { t } = useTranslation(); const sorted = [...slots].sort((a, b) => a.order - b.order); @@ -921,6 +1104,9 @@ function PanelColumn({ activity={activity} onClearActivity={onClearActivity} isAdmin={isAdmin} + serviceLinks={serviceLinks} + onAddServiceLink={onAddServiceLink} + onDeleteServiceLink={onDeleteServiceLink} />
))} @@ -1028,7 +1214,7 @@ export function DashboardTab({ const [activeTunnelCount, setActiveTunnelCount] = useState(0); const [activity, setActivity] = useState([]); const [hostMetrics, setHostMetrics] = useState< - Map + Map >(new Map()); const viewerSessionsRef = useRef>(new Map()); @@ -1066,6 +1252,7 @@ export function DashboardTab({ id: host.id, cpu: metrics.cpu?.percent ?? null, ram: metrics.memory?.percent ?? null, + disk: metrics.disk?.percent ?? null, }; } catch { return null; @@ -1074,9 +1261,12 @@ export function DashboardTab({ ); viewerSessionsRef.current = newSessions; - const map = new Map(); + const map = new Map< + string, + { cpu: number | null; ram: number | null; disk: number | null } + >(); for (const r of results) { - if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram }); + if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram, disk: r.disk }); } setHostMetrics(map); }, []); @@ -1169,6 +1359,24 @@ export function DashboardTab({ } }; + const [serviceLinks, setServiceLinks] = useState([]); + + useEffect(() => { + getServiceLinks() + .then(setServiceLinks) + .catch(() => {}); + }, []); + + const handleAddServiceLink = async (label: string, url: string) => { + const created = await createServiceLink(label, url); + setServiceLinks((prev) => [...prev, created]); + }; + + const handleDeleteServiceLink = async (id: number) => { + await deleteServiceLink(id); + setServiceLinks((prev) => prev.filter((l) => l.id !== id)); + }; + const todayLabel = new Date().toLocaleDateString(i18n.language, { weekday: "long", month: "long", @@ -1192,6 +1400,7 @@ export function DashboardTab({ host_status: t("dashboardTab.hostStatus"), recent_activity: t("dashboard.recentActivity"), network_graph: t("dashboard.networkGraph"), + service_links: t("dashboard.serviceLinks"), }; const onColumnDividerMouseDown = useCallback( @@ -1264,7 +1473,9 @@ export function DashboardTab({ ? 350 : id === "host_status" || id === "recent_activity" ? null - : 150; + : id === "service_links" + ? 200 + : 150; return [...prev, { id, panel, order: maxOrder, height: defaultHeight }]; }); }; @@ -1299,6 +1510,9 @@ export function DashboardTab({ onOpenTab, cardLabels, isAdmin, + serviceLinks, + onAddServiceLink: handleAddServiceLink, + onDeleteServiceLink: handleDeleteServiceLink, }; const isMobile = useIsMobile(); @@ -1393,6 +1607,7 @@ export function DashboardTab({ hosts={hosts} credentialCount={credentialCount} activeTunnelCount={activeTunnelCount} + onOpenSingletonTab={onOpenSingletonTab} /> )} {slot.id === "quick_actions" && ( @@ -1424,6 +1639,13 @@ export function DashboardTab({ onOpenInNewTab={() => onOpenSingletonTab("network_graph")} /> )} + {slot.id === "service_links" && ( + + )}
))}
diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index 9505dab79..4cec13a39 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -128,11 +128,17 @@ function buildNodeSvg( ): string { const isOnline = status === "online"; const isOffline = status === "offline"; - const statusColor = isOnline - ? "rgb(16,185,129)" - : isOffline - ? "rgb(239,68,68)" - : "rgb(100,116,139)"; + const useRealColors = localStorage.getItem("statusColorScheme") === "status"; + let statusColor: string; + if (isOnline) { + statusColor = useRealColors + ? "rgb(16,185,129)" + : resolveCssVar("--accent-brand", "rgb(16,185,129)"); + } else if (isOffline) { + statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)"; + } else { + statusColor = "rgb(100,116,139)"; + } const bg = resolveCssVar("--card", "#1e1e20"); const border = resolveCssVar("--border", "#2a2a2c"); diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 1290b88f2..de245a556 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -79,7 +79,12 @@ import type { } from "./file-manager-types.ts"; import { formatFileSize } from "./file-manager-utils.ts"; -function FileManagerContent({ initialHost, onClose }: FileManagerProps) { +function FileManagerContent({ + initialHost, + initialFilePath, + initialPath, + onClose, +}: FileManagerProps) { const { openWindow } = useWindowManager(); const { t } = useTranslation(); const formatTransferMetrics = useMemo( @@ -95,10 +100,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [currentHost] = useState(initialHost || null); const [currentPath, setCurrentPath] = useState( - initialHost?.defaultPath || "/", + initialPath || initialHost?.defaultPath || "/", ); const [navHistory, setNavHistory] = useState([ - initialHost?.defaultPath || "/", + initialPath || initialHost?.defaultPath || "/", ]); const [navIndex, setNavIndex] = useState(0); const [files, setFiles] = useState([]); @@ -269,6 +274,60 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { }; }, [sshSessionId, startKeepalive, stopKeepalive]); + const initialFileOpenedRef = useRef(false); + useEffect(() => { + if (!sshSessionId || !initialFilePath || initialFileOpenedRef.current) + return; + initialFileOpenedRef.current = true; + + const fileName = initialFilePath.split("/").pop() || initialFilePath; + const fileDir = + initialFilePath.lastIndexOf("/") > 0 + ? initialFilePath.substring(0, initialFilePath.lastIndexOf("/")) + : "/"; + + setCurrentPath(fileDir); + + const file: FileItem = { + name: fileName, + path: initialFilePath, + type: "file", + }; + + const windowCount = Date.now() % 10; + const offsetX = Math.min( + 120 + windowCount * 30, + Math.max(0, window.innerWidth - 820), + ); + const offsetY = Math.min( + 120 + windowCount * 30, + Math.max(0, window.innerHeight - 620), + ); + + const createWindowComponent = (windowId: string) => ( + + ); + + openWindow({ + title: fileName, + x: offsetX, + y: offsetY, + width: 800, + height: 600, + isMaximized: false, + isMinimized: false, + component: createWindowComponent, + }); + }, [sshSessionId, initialFilePath]); + const initialLoadDoneRef = useRef(false); const lastPathChangeRef = useRef(""); const pathChangeTimerRef = useRef(null); @@ -1365,6 +1424,23 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { }); } + function handleCopyFolderLink(path: string) { + if (!currentHost?.id) return; + const params = new URLSearchParams({ + view: "file-manager", + hostId: String(currentHost.id), + path, + }); + const url = `${window.location.origin}?${params.toString()}`; + copyToClipboard(url).then((ok) => { + if (ok) { + toast.success(t("fileManager.folderLinkCopied")); + } else { + toast.error(t("fileManager.failedToCopyFolderLink")); + } + }); + } + async function handlePasteFiles() { if (!clipboard || !sshSessionId) return; @@ -2836,6 +2912,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { onExtractArchive={handleExtractArchive} onCompress={handleOpenCompressDialog} onCopyPath={handleCopyPath} + onCopyFolderLink={handleCopyFolderLink} onTransferToHost={handleOpenTransferDialog} />
@@ -2907,10 +2984,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { ); } -function FileManagerInner({ initialHost, onClose }: FileManagerProps) { +function FileManagerInner({ + initialHost, + initialFilePath, + initialPath, + onClose, +}: FileManagerProps) { return ( - + ); } diff --git a/src/ui/features/file-manager/FileManagerApp.tsx b/src/ui/features/file-manager/FileManagerApp.tsx index f702c6a74..ea38f2b29 100644 --- a/src/ui/features/file-manager/FileManagerApp.tsx +++ b/src/ui/features/file-manager/FileManagerApp.tsx @@ -5,9 +5,13 @@ import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx"; interface FileManagerAppProps { hostId?: string; + initialPath?: string; } -const FileManagerApp: React.FC = ({ hostId }) => { +const FileManagerApp: React.FC = ({ + hostId, + initialPath, +}) => { const { t } = useTranslation(); return ( @@ -39,6 +43,7 @@ const FileManagerApp: React.FC = ({ hostId }) => { {}} /> ); diff --git a/src/ui/features/file-manager/FileManagerContextMenu.tsx b/src/ui/features/file-manager/FileManagerContextMenu.tsx index 5dd9d86bb..1c7946a37 100644 --- a/src/ui/features/file-manager/FileManagerContextMenu.tsx +++ b/src/ui/features/file-manager/FileManagerContextMenu.tsx @@ -19,6 +19,7 @@ import { Bookmark, FileArchive, ArrowRightLeft, + Link, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx"; @@ -67,6 +68,7 @@ interface ContextMenuProps { onExtractArchive?: (file: FileItem) => void; onCompress?: (files: FileItem[]) => void; onCopyPath?: (files: FileItem[]) => void; + onCopyFolderLink?: (path: string) => void; onTransferToHost?: (files: FileItem[], move: boolean) => void; } @@ -110,6 +112,7 @@ export function FileManagerContextMenu({ onExtractArchive, onCompress, onCopyPath, + onCopyFolderLink, onTransferToHost, }: ContextMenuProps) { const { t } = useTranslation(); @@ -350,12 +353,22 @@ export function FileManagerContextMenu({ }); } + if (isSingleFile && files[0].type === "directory" && onCopyFolderLink) { + menuItems.push({ + icon: , + label: t("fileManager.copyFolderLink"), + action: () => onCopyFolderLink(files[0].path), + }); + } + if ( (hasFiles && (onPreview || onDragToDesktop)) || (isSingleFile && files[0].type === "file" && (onPinFile || onUnpinFile)) || - (isSingleFile && files[0].type === "directory" && onAddShortcut) + (isSingleFile && + files[0].type === "directory" && + (onAddShortcut || onCopyFolderLink)) ) { menuItems.push({ separator: true } as MenuItem); } @@ -491,6 +504,15 @@ export function FileManagerContextMenu({ shortcut: "Ctrl+V", }); } + + if (onCopyFolderLink && currentPath) { + menuItems.push({ separator: true } as MenuItem); + menuItems.push({ + icon: , + label: t("fileManager.copyCurrentFolderLink"), + action: () => onCopyFolderLink(currentPath), + }); + } } const filteredMenuItems = menuItems.filter((item, index) => { diff --git a/src/ui/features/file-manager/components/CodeEditor.tsx b/src/ui/features/file-manager/components/CodeEditor.tsx index 4e6cb7189..40d005b71 100644 --- a/src/ui/features/file-manager/components/CodeEditor.tsx +++ b/src/ui/features/file-manager/components/CodeEditor.tsx @@ -23,6 +23,7 @@ interface CodeEditorProps { onChange: (value: string) => void; onFocus: () => void; onBlur: () => void; + fontSize?: number; } function getLanguageExtension(filename: string) { @@ -73,7 +74,7 @@ function getLanguageExtension(filename: string) { export const CodeEditor = forwardRef( function CodeEditor( - { fileName, value, placeholder, onChange, onFocus, onBlur }, + { fileName, value, placeholder, onChange, onFocus, onBlur, fontSize = 14 }, ref, ) { const editorRef = useRef<{ view?: EditorView } | null>(null); @@ -105,6 +106,7 @@ export const CodeEditor = forwardRef( EditorView.theme({ "&": { height: "100%", + fontSize: `${fontSize}px`, }, ".cm-scroller": { overflow: "auto", @@ -116,7 +118,7 @@ export const CodeEditor = forwardRef( }, }), ]; - }, [fileName]); + }, [fileName, fontSize]); useImperativeHandle( ref, diff --git a/src/ui/features/file-manager/components/FileViewer.tsx b/src/ui/features/file-manager/components/FileViewer.tsx index 6ae9a8c6a..5e968af93 100644 --- a/src/ui/features/file-manager/components/FileViewer.tsx +++ b/src/ui/features/file-manager/components/FileViewer.tsx @@ -17,6 +17,8 @@ import { RotateCcw, Keyboard, Search, + ZoomIn, + ZoomOut, } from "lucide-react"; import { SiJavascript, @@ -282,8 +284,31 @@ export function FileViewer({ const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false); const [editorFocused, setEditorFocused] = useState(false); const [markdownEditMode, setMarkdownEditMode] = useState(false); + const [editorFontSize, setEditorFontSize] = useState(() => { + const stored = localStorage.getItem("fileManagerEditorFontSize"); + return stored ? parseInt(stored, 10) : 14; + }); const editorRef = useRef(null); + const MIN_FONT_SIZE = 8; + const MAX_FONT_SIZE = 32; + + const decreaseFontSize = () => { + setEditorFontSize((prev) => { + const next = Math.max(MIN_FONT_SIZE, prev - 1); + localStorage.setItem("fileManagerEditorFontSize", String(next)); + return next; + }); + }; + + const increaseFontSize = () => { + setEditorFontSize((prev) => { + const next = Math.min(MAX_FONT_SIZE, prev + 1); + localStorage.setItem("fileManagerEditorFontSize", String(next)); + return next; + }); + }; + const fileTypeInfo = getFileType(file.name); const WARNING_SIZE = 50 * 1024 * 1024; @@ -415,6 +440,33 @@ export function FileViewer({ )} + {isEditable && ( +
+ + + {editorFontSize}px + + +
+ )} {isEditable && ( - - -
- - {/* Tab / Shift+Tab */} - + + +
+ + {/* Tab / Shift+Tab */} + + + {/* Esc */} + + + {/* Arrow keys */} +
+ + + + + + {/* Ctrl combos row -- only visible when Ctrl is active */} + {ctrlActive && ( + <> +
+ {["c", "d", "l", "u", "z", "a"].map((k) => ( + + ))} + )} - onPointerDown={(e) => { - e.preventDefault(); - handleTab(); - }} - title={ - shiftActive ? t("mobileKeyboard.shiftTab") : t("mobileKeyboard.tab") - } - > - {shiftActive ? t("mobileKeyboard.shiftTab") : t("mobileKeyboard.tab")} - - - {/* Esc */} - - - {/* Arrow keys */} -
- - - - - - {/* Ctrl combos row — only visible when Ctrl is active */} - {ctrlActive && ( - <> -
- {["c", "d", "l", "u", "z", "a"].map((k) => ( +
+ + {/* Quick keys row */} +
+ {quickKeys.map((sym, i) => + editing ? ( +
+ + +
+ ) : ( + + ), + )} + + {editing && ( +
+ setNewSymbol(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") addQuickKey(); + }} + /> + - ))} - - )} +
+ )} + +
+ +
+
); } diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 3328a59a8..29c2c3b44 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -75,6 +75,7 @@ interface SSHTerminalProps { /** Attach to this tmux session right after connecting (tmux monitor). */ tmuxAttachSession?: string; onOpenFileManager?: (path?: string) => void; + onOpenFileInEditor?: (filePath: string) => void; previewTheme?: string | null; } @@ -85,10 +86,12 @@ const TerminalInner = forwardRef( isVisible, splitScreen = false, onClose, + onTitleChange, initialPath, executeCommand, tmuxAttachSession, onOpenFileManager, + onOpenFileInEditor, previewTheme, }, ref, @@ -114,7 +117,11 @@ const TerminalInner = forwardRef( const activeTheme = previewTheme || config.theme; const themeColors = resolveTermixThemeColors(activeTheme, appTheme); - const backgroundColor = themeColors.background; + const backgroundImage = config.backgroundImage || ""; + const backgroundImageOpacity = config.backgroundImageOpacity ?? 0.15; + const backgroundColor = backgroundImage + ? "transparent" + : themeColors.background; const fitAddonRef = useRef(null); const webSocketRef = useRef(null); const resizeTimeout = useRef(null); @@ -176,6 +183,10 @@ const TerminalInner = forwardRef( const pendingRestoredSessionIdRef = useRef( hostConfig.restoredSessionId ?? null, ); + const [linkClickDialog, setLinkClickDialog] = useState<{ + url: string; + } | null>(null); + const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{ sessions: Array<{ name: string; @@ -943,6 +954,24 @@ const TerminalInner = forwardRef( ); } terminal.onData((data) => { + if (data === "\r" || data === "\n") { + const currentCmd = getCurrentCommand().trim(); + const termixMatch = currentCmd.match(/^termix\s+(.+)$/); + if (termixMatch && onOpenFileInEditor) { + const filePath = termixMatch[1].trim(); + trackInput(data); + terminal.write("\r\n"); + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "open_file_in_editor", + path: filePath, + }), + ); + } + return; + } + } trackInput(data); ws.send(JSON.stringify({ type: "input", data })); }); @@ -1401,6 +1430,8 @@ const TerminalInner = forwardRef( } } else if (msg.type === "cwd") { onOpenFileManager?.(msg.path as string); + } else if (msg.type === "open_file_in_editor") { + onOpenFileInEditor?.(msg.path as string); } else if (msg.type === "passphrase_required") { setShowPassphraseDialog(true); setIsConnecting(false); @@ -1807,7 +1838,9 @@ const TerminalInner = forwardRef( | "both"; terminal.options.theme = { - background: themeColors.background, + background: config.backgroundImage + ? "transparent" + : themeColors.background, foreground: themeColors.foreground, cursor: themeColors.cursor, cursorAccent: themeColors.cursorAccent, @@ -1875,7 +1908,9 @@ const TerminalInner = forwardRef( lineHeight: config.lineHeight, bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both", theme: { - background: themeColors.background, + background: config.backgroundImage + ? "transparent" + : themeColors.background, foreground: themeColors.foreground, cursor: themeColors.cursor, cursorAccent: themeColors.cursorAccent, @@ -1909,7 +1944,17 @@ const TerminalInner = forwardRef( uri.startsWith("http://") || uri.startsWith("https://") ? uri : `https://${uri}`; - window.open(url, "_blank"); + + const hostBehavior = hostConfig.terminalConfig?.linkClickBehavior; + const globalBehavior = + localStorage.getItem("terminalLinkClickBehavior") ?? "confirm"; + const behavior = hostBehavior ?? globalBehavior; + + if (behavior === "direct") { + window.open(url, "_blank"); + } else { + setLinkClickDialog({ url }); + } }); fitAddonRef.current = fitAddon; @@ -1921,6 +1966,9 @@ const TerminalInner = forwardRef( terminal.unicode.activeVersion = "11"; terminal.open(xtermRef.current); + terminal.onTitleChange((title) => { + if (title) onTitleChange?.(title); + }); document.fonts.ready.then(() => { terminal.refresh(0, terminal.rows - 1); fitAddon.fit(); @@ -2159,6 +2207,38 @@ const TerminalInner = forwardRef( } } + if ( + e.ctrlKey && + e.shiftKey && + !e.altKey && + !e.metaKey && + e.key.toLowerCase() === "c" + ) { + const selection = terminal.getSelection(); + if (selection) { + e.preventDefault(); + e.stopPropagation(); + writeTextToClipboard(selection); + terminal.clearSelection(); + return false; + } + } + + if ( + e.ctrlKey && + e.shiftKey && + !e.altKey && + !e.metaKey && + e.key.toLowerCase() === "v" + ) { + e.preventDefault(); + e.stopPropagation(); + readTextFromClipboard().then((text) => { + if (text) terminal.paste(text); + }); + return false; + } + if ( e.ctrlKey && !e.shiftKey && @@ -2453,10 +2533,32 @@ const TerminalInner = forwardRef( const hasConnectionError = !!connectionError; return ( -
+
+ {backgroundImage && ( +
+ )}
( position={autocompletePosition} onSelect={handleAutocompleteSelect} /> + + {linkClickDialog && ( +
+
+

+ {t("terminal.linkDialogTitle")} +

+

+ {linkClickDialog.url} +

+
+ + + +
+
+
+ )}
); }, diff --git a/src/ui/features/terminal/terminal-global-styles.ts b/src/ui/features/terminal/terminal-global-styles.ts index e181bfa8d..15913c392 100644 --- a/src/ui/features/terminal/terminal-global-styles.ts +++ b/src/ui/features/terminal/terminal-global-styles.ts @@ -1,9 +1,5 @@ const style = document.createElement("style"); style.innerHTML = ` -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap'); - @font-face { font-family: 'Caskaydia Cove Nerd Font Mono'; src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype'); diff --git a/src/ui/hooks/use-status-color-scheme.ts b/src/ui/hooks/use-status-color-scheme.ts new file mode 100644 index 000000000..45bbb0b04 --- /dev/null +++ b/src/ui/hooks/use-status-color-scheme.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; + +export type StatusColorScheme = "accent" | "status"; + +export function useStatusColorScheme(): StatusColorScheme { + const [scheme, setScheme] = useState( + () => + (localStorage.getItem("statusColorScheme") as StatusColorScheme) ?? + "accent", + ); + + useEffect(() => { + const handler = () => { + setScheme( + (localStorage.getItem("statusColorScheme") as StatusColorScheme) ?? + "accent", + ); + }; + window.addEventListener("statusColorSchemeChanged", handler); + return () => + window.removeEventListener("statusColorSchemeChanged", handler); + }, []); + + return scheme; +} + +/** Returns Tailwind class names for a status dot/stripe. */ +export function getStatusClasses( + online: boolean, + scheme: StatusColorScheme, + variant: "dot" | "stripe" | "badge", +): string { + if (scheme === "status") { + if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500"; + if (variant === "stripe") + return online ? "bg-emerald-500" : "bg-red-500/40"; + // badge + return online + ? "border-emerald-500/40 text-emerald-500 bg-emerald-500/10" + : "border-red-500/40 text-red-500 bg-red-500/10"; + } + // accent scheme + if (variant === "dot") + return online ? "bg-accent-brand" : "bg-accent-brand/20"; + if (variant === "stripe") + return online ? "bg-accent-brand" : "bg-accent-brand/20"; + // badge + return online + ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" + : "border-accent-brand/20 text-accent-brand/40 bg-accent-brand/5"; +} diff --git a/src/ui/index.css b/src/ui/index.css index 8bedf7da3..29047c70c 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -1,6 +1,16 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "@fontsource-variable/jetbrains-mono"; +@import "@fontsource/jetbrains-mono/400.css"; +@import "@fontsource/jetbrains-mono/400-italic.css"; +@import "@fontsource/jetbrains-mono/700.css"; +@import "@fontsource/jetbrains-mono/700-italic.css"; +@import "@fontsource/fira-code/400.css"; +@import "@fontsource/fira-code/700.css"; +@import "@fontsource/source-code-pro/400.css"; +@import "@fontsource/source-code-pro/400-italic.css"; +@import "@fontsource/source-code-pro/700.css"; +@import "@fontsource/source-code-pro/700-italic.css"; @font-face { font-family: "Caskaydia Cove Nerd Font Mono"; @@ -242,38 +252,38 @@ .nord { --background: #2e3440; --foreground: #eceff4; - --card: #272c36; + --card: #303642; --card-foreground: #eceff4; - --popover: #272c36; + --popover: #303642; --popover-foreground: #eceff4; --primary: #eceff4; --primary-foreground: #2e3440; --secondary: #3b4252; --secondary-foreground: #eceff4; - --surface: #272c36; - --surface-dim: #22262e; + --surface: #303642; + --surface-dim: #262b35; --muted: #3b4252; - --muted-foreground: #4c566a; + --muted-foreground: #8899b0; --accent: #3b4252; --accent-foreground: #eceff4; --destructive: #bf616a; - --border: rgba(76, 86, 106, 0.35); - --input: rgba(76, 86, 106, 0.3); - --ring: #4c566a; + --border: rgba(136, 192, 208, 0.2); + --input: rgba(136, 192, 208, 0.15); + --ring: #88c0d0; --chart-1: #bf616a; --chart-2: #88c0d0; --chart-3: #a3be8c; --chart-4: #ebcb8b; --chart-5: #b48ead; --radius: 0.625rem; - --sidebar: #272c36; + --sidebar: #282d39; --sidebar-foreground: #eceff4; --sidebar-primary: #88c0d0; --sidebar-primary-foreground: #2e3440; --sidebar-accent: #3b4252; --sidebar-accent-foreground: #eceff4; - --sidebar-border: rgba(76, 86, 106, 0.35); - --sidebar-ring: #4c566a; + --sidebar-border: rgba(136, 192, 208, 0.2); + --sidebar-ring: #88c0d0; } /* ── Solarized Dark ─────────────────────────────────────────── */ diff --git a/src/ui/lib/terminal-themes.ts b/src/ui/lib/terminal-themes.ts index d4f80bc79..d96bfab61 100644 --- a/src/ui/lib/terminal-themes.ts +++ b/src/ui/lib/terminal-themes.ts @@ -821,6 +821,8 @@ export const DEFAULT_TERMINAL_CONFIG = { keepaliveInterval: undefined as number | undefined, keepaliveCountMax: undefined as number | undefined, autoTmux: false, + backgroundImage: "" as string, + backgroundImageOpacity: 0.15, }; export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG; diff --git a/src/ui/lib/theme.ts b/src/ui/lib/theme.ts index 05cfffe60..71a435572 100644 --- a/src/ui/lib/theme.ts +++ b/src/ui/lib/theme.ts @@ -41,6 +41,12 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [ description: "Visual map of host network topology", defaultEnabled: false, }, + { + id: "service_links", + label: "Service Links", + description: "Clickable buttons linking to services on your servers", + defaultEnabled: false, + }, ]; export const ACCENT_PRESET_COLORS = [ diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 7e08e5f42..5861f649e 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -58,7 +58,10 @@ "embeddedDesc": "Run Termix with the built-in local server (no remote server needed)", "embeddedConnecting": "Connecting to local server...", "embeddedNotReady": "Local server is not ready yet. Please wait a moment and try again.", - "localServer": "Local Server" + "localServer": "Local Server", + "savedServers": "Saved Servers", + "noSavedServers": "No saved servers", + "removeServer": "Remove" }, "versionCheck": { "error": "Version Check Error", @@ -105,6 +108,7 @@ "cancel": "Cancel", "username": "Username", "login": "Login", + "logout": "Logout", "register": "Register", "password": "Password", "confirmPassword": "Confirm Password", @@ -165,6 +169,7 @@ "noPasswordAvailable": "No password available", "failedToCopyPassword": "Failed to copy password", "refreshTab": "Refresh connection", + "renameTab": "Rename tab", "openFileManager": "Open File Manager", "dashboard": "Dashboard", "networkGraph": "Network Graph", @@ -190,6 +195,7 @@ "downloadSample": "Download Sample", "failedToDeleteHost": "Failed to delete {{name}}", "importSkipExisting": "Import (skip existing)", + "importSSHConfig": "Import from SSH config", "connectionDetails": "Connection Details", "ssh": "SSH", "telnet": "Telnet", @@ -264,6 +270,7 @@ "forceKeyboardInteractive": "Force Keyboard-Interactive", "overrideCredentialUsername": "Override Credential Username", "overrideCredentialUsernameDesc": "Use the username specified above instead of the credential's username", + "oidcUsernameHint": "Use $oidc.preferred_username to substitute your OIDC login name.", "jumpHostChain": "Jump Host Chain", "portKnocking": "Port Knocking", "addKnock": "Add Port", @@ -297,6 +304,8 @@ "addressIp": "Address / IP", "friendlyName": "Friendly Name", "macAddress": "MAC Address", + "wolBroadcastAddress": "WoL Broadcast Address", + "wolBroadcastAddressDesc": "Optional directed broadcast for Docker/routed networks (e.g. 192.168.1.255). Leave empty to use 255.255.255.255.", "folderAndAdvanced": "Folder & Advanced", "privateNotes": "Private Notes", "privateNotesPlaceholder": "Details about this server...", @@ -350,6 +359,9 @@ "tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.", "forceKeyboardInteractiveLabel": "Force Keyboard Interactive", "forceKeyboardInteractiveShortDesc": "Force manual password entry even if keys are present", + "allowLegacyAlgorithmsLabel": "Allow Legacy Algorithms", + "allowLegacyAlgorithmsDesc": "Enable deprecated SSH algorithms (ssh-dss, ssh-rsa, diffie-hellman-group1-sha1, hmac-md5, 3des-cbc) for connections to old devices that cannot be upgraded.", + "insecure": "Insecure", "terminalAppearance": "Terminal Appearance", "colorTheme": "Color Theme", "fontFamilyLabel": "Font Family", @@ -363,6 +375,9 @@ "cursorBlinkingDesc": "Enable blinking animation for the terminal cursor", "rightClickSelectsWordLabel": "Right-click Selects Word", "rightClickSelectsWordShortDesc": "Select the word under cursor on right-click", + "backgroundImageLabel": "Background Image URL", + "backgroundImageDesc": "Optional URL for a terminal background image", + "backgroundImageOpacityLabel": "Background Image Opacity", "syntaxHighlightingLabel": "Syntax Highlighting", "syntaxHighlightingDesc": "Colorize terminal output (errors, paths, IPs, timestamps)", "syntaxHighlightingCategories": "Highlight Categories", @@ -392,6 +407,11 @@ "enableSessionLoggingDesc": "Record terminal session output for later review", "enableCommandHistory": "Command History", "enableCommandHistoryDesc": "Record commands run in this terminal for history and autocomplete", + "linkClickBehaviorLabel": "Link Click Behavior", + "linkClickBehaviorDesc": "Controls what happens when you click a link in the terminal. Use 'Default' to follow the app-level setting.", + "linkClickBehaviorDefault": "Default (follow app setting)", + "linkClickBehaviorConfirm": "Show popup to open or copy", + "linkClickBehaviorDirect": "Open directly", "sudoPasswordAutoFillLabel": "Sudo Password Auto-fill", "sudoPasswordAutoFillShortDesc": "Automatically provide sudo password when prompted", "sudoPasswordAutoFillDesc": "Store a sudo password so host metrics, terminal prompts, and others can run privileged commands automatically.", @@ -438,8 +458,15 @@ "proxmoxIntegration": "Proxmox Integration", "enableProxmox": "Enable Proxmox", "enableProxmoxDesc": "Mark this host as a Proxmox node. Enables guest discovery and import directly from this host.", + "proxmoxDefaultAuthType": "Default Auth Type", + "proxmoxDefaultAuthTypeDesc": "Authentication method applied to imported guest hosts. Choose the auth type that matches how you connect to your guests.", + "authTypePassword": "Password", + "authTypeKey": "SSH Key", + "authTypeCredential": "Credential", + "authTypeOpkssh": "OPKSSH", + "authTypeNone": "None", "proxmoxDefaultCredential": "Default Credential", - "proxmoxDefaultCredentialDesc": "Credential used for imported guest hosts. The credential's username is applied to all guests.", + "proxmoxDefaultCredentialDesc": "Credential used for imported guest hosts when auth type is set to Credential.", "proxmoxWindowsDetection": "Windows / RDP detection", "proxmoxWindowsDetectionDesc": "Comma-separated name patterns that trigger RDP instead of SSH (case-insensitive)", "proxmoxDockerDetection": "Docker detection", @@ -467,6 +494,8 @@ "proxmoxImportFailed": "Import failed", "enableFileManagerMonitor": "Enable File Manager", "enableFileManagerMonitorDesc": "Browse and manage files on this host over SFTP", + "scpLegacyLabel": "SCP Legacy Mode", + "scpLegacyDesc": "Use legacy file transfer for servers that do not support the SFTP subsystem (e.g. embedded or minimal SSH servers).", "defaultPathLabel": "Default Path", "fileManagerPathHint": "The directory to open when the file manager launches for this host.", "statusChecksLabel": "Status Checks", @@ -689,6 +718,7 @@ "nSelected": "{{count}} selected", "featuresMenu": "Features", "moveMenu": "Move", + "connectSelected": "Connect", "cancelSelection": "Cancel", "deployDialogDesc": "Deploy {{name}} to a host's authorized_keys.", "targetHostLabel": "Target Host", @@ -749,6 +779,8 @@ "guac": { "connection": "Connection", "authentication": "Authentication", + "storedCredential": "Stored Credential", + "noCredential": "No credential (direct credentials below)", "connectionSettings": "Connection Settings", "displaySettings": "Display Settings", "audioSettings": "Audio Settings", @@ -776,6 +808,13 @@ "initialProgram": "Initial Program", "serverLayout": "Server Layout", "timezone": "Timezone", + "loadBalanceInfo": "Load Balance Info / Cookie", + "loadBalanceInfoDesc": "RD Connection Broker cookie for RDS farm load balancing (e.g. tsv://MS Terminal Services Plugin.1.CollectionName)", + "guacdProxy": "guacd Proxy", + "guacdHostname": "guacd Host", + "guacdHostnamePlaceholder": "Global default", + "guacdPort": "guacd Port", + "guacdProxyDesc": "Override the global guacd instance for this connection. Leave blank to use the globally configured guacd.", "gatewayHostname": "Gateway Hostname", "gatewayPort": "Gateway Port", "gatewayUsername": "Gateway Username", @@ -940,7 +979,8 @@ "persisted": "Persisted in background", "expiresIn": "Expires in {{duration}}", "search": "Search connections...", - "noSearchResults": "No connections match your search" + "noSearchResults": "No connections match your search", + "rename": "Rename session" }, "guacamole": { "connecting": "Connecting to {{type}} session...", @@ -1037,6 +1077,9 @@ "opksshAuthFailed": "Authentication failed. Please check your credentials and try again.", "opksshSignInWith": "Sign in with {{provider}}", "sudoPasswordPopupTitle": "Insert Password?", + "linkDialogTitle": "Open Link", + "linkDialogOpen": "Open", + "linkDialogCopy": "Copy", "websocketAbnormalClose": "Connection closed unexpectedly. This may be due to a reverse proxy or SSL configuration issue. Please check server logs.", "connectionLogTitle": "Connection Log", "connectionLogCopy": "Copy logs to clipboard", @@ -1127,6 +1170,10 @@ "pathCopiedToClipboard": "Path copied to clipboard", "pathsCopiedToClipboard": "{{count}} paths copied to clipboard", "failedToCopyPath": "Failed to copy path to clipboard", + "copyFolderLink": "Copy Link to Folder", + "copyCurrentFolderLink": "Copy Link to Current Folder", + "folderLinkCopied": "Folder link copied to clipboard", + "failedToCopyFolderLink": "Failed to copy folder link", "movedItems": "Moved {{count}} items", "failedToDeleteItem": "Failed to delete item", "itemRenamedSuccessfully": "{{type}} renamed successfully", @@ -1236,6 +1283,8 @@ "move": "Move", "searchInFile": "Search in file (Ctrl+F)", "showKeyboardShortcuts": "Show keyboard shortcuts", + "decreaseFontSize": "Decrease font size", + "increaseFontSize": "Increase font size", "startWritingMarkdown": "Start writing your markdown content...", "loadingFileComparison": "Loading file comparison...", "reload": "Reload", @@ -1636,7 +1685,7 @@ }, "auth": { "tagline": "Self-hosted SSH and remote desktop management", - "loginTitle": "Login to Termix", + "loginTitle": "Welcome back", "registerTitle": "Create Account", "forgotPassword": "Forgot Password?", "rememberMe": "Remember Device for 30 Days (includes TOTP)", @@ -1817,7 +1866,8 @@ "serverStatsCard": "Server Stats", "panelMain": "Main", "panelSide": "Side", - "justNow": "just now" + "justNow": "just now", + "serviceLinks": "Service Links" }, "dashboardTab": { "stable": "STABLE", @@ -1838,7 +1888,17 @@ "done": "Done", "editModeInstructions": "Drag cards to reorder · Drag the column divider to resize columns · Drag the bottom edge of a card to resize its height · Trash to remove", "empty": "Empty", - "clear": "Clear" + "clear": "Clear", + "serviceLinksTitle": "Service Links", + "serviceLinksEmpty": "No service links yet", + "serviceLinksAddLabel": "Label", + "serviceLinksAddUrl": "URL", + "serviceLinksAdd": "Add", + "serviceLinksLabelPlaceholder": "My Service", + "serviceLinksUrlPlaceholder": "http://192.168.1.10:8080", + "serviceLinksInvalidUrl": "Must be a valid http or https URL", + "disk": "Disk", + "viewServerDetails": "View server details" }, "sessionLogs": { "title": "Session Logs", @@ -2041,6 +2101,38 @@ "sectionDatabase": "Database", "sectionApiKeys": "API Keys", "sectionAuditLog": "Audit Log", + "sectionSsl": "SSL / Let's Encrypt", + "sslDescription": "Automatically issue and renew a trusted SSL certificate from Let's Encrypt. Requires a public domain and port 80 or DNS access.", + "sslDocsLink": "View SSL docs", + "sslDomain": "Domain", + "sslDomainPlaceholder": "termix.example.com", + "sslDomainDesc": "The public domain name for the certificate.", + "sslEmail": "Email", + "sslEmailPlaceholder": "admin@example.com", + "sslEmailDesc": "Contact email for Let's Encrypt notifications and account.", + "sslChallengeType": "Challenge Type", + "sslChallengeTypeDesc": "How to prove domain ownership to Let's Encrypt.", + "sslChallengeHttp": "HTTP (webroot) - requires port 80 accessible from the internet", + "sslChallengeDns": "DNS (Cloudflare) - requires a Cloudflare API token", + "sslCloudflareToken": "Cloudflare API Token", + "sslCloudflareTokenPlaceholder": "Enter token...", + "sslCloudflareTokenDesc": "Scoped token with Zone:DNS:Edit permission for your domain.", + "sslCertStatus": "Certificate Status", + "sslCertStatusNone": "No certificate", + "sslCertStatusValid": "Valid", + "sslCertStatusExpiring": "Expiring soon", + "sslCertStatusExpired": "Expired", + "sslCertExpiresAt": "Expires {{date}}", + "sslLastIssued": "Last issued {{date}}", + "sslRequestCert": "Issue / Renew Certificate", + "sslRequestCertLoading": "Requesting certificate...", + "sslRequestCertSuccess": "Certificate issued and installed successfully", + "sslRequestCertFailed": "Certificate request failed", + "sslSave": "Save Settings", + "sslSaved": "SSL settings saved", + "sslSaveFailed": "Failed to save SSL settings", + "sslRequiresDomain": "Domain and email are required", + "sslInfoNote": "After issuing a certificate, enable SSL in your environment variables (ENABLE_SSL=true) and restart Termix.", "auditLogTotal": "{{total}} total entries", "auditLogEmpty": "No audit log entries found", "auditLogSuccess": "Success", @@ -2057,11 +2149,13 @@ "auditLogFilterTo": "To", "auditLogFilterAll": "All", "allowRegistration": "Allow User Registration", - "allowRegistrationDesc": "Let new users self-register", + "allowRegistrationDesc": "Let new users self-register with a username and password", "allowPasswordLogin": "Allow Password Login", "allowPasswordLoginDesc": "Username/password login", "oidcAutoProvision": "OIDC Auto-Provision", - "oidcAutoProvisionDesc": "Auto-create accounts for OIDC users even when registration is disabled", + "oidcAutoProvisionDesc": "Auto-create accounts for OIDC/SSO users on first login (independent of the registration toggle)", + "oidcSilentLoginDefault": "Silent OIDC Login by Default", + "oidcSilentLoginDefaultDesc": "Automatically redirect to OIDC login on every visit, skipping the login form entirely", "allowPasswordReset": "Allow Password Reset", "allowPasswordResetDesc": "Reset code via Docker logs", "commandHistoryEnabled": "Command History", @@ -2099,6 +2193,8 @@ "oidcAdminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.", "oidcGroupClaim": "Group Claim", "oidcGroupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).", + "oidcCaCert": "Custom CA Certificate", + "oidcCaCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store.", "removeOidc": "Remove", "usersCount": "{{count}} users", "createUser": "Create", @@ -2210,6 +2306,7 @@ "updatePasswordLoginFailed": "Failed to update password login setting", "cannotDisablePasswordLoginWithTotp": "Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.", "updateOidcAutoProvisionFailed": "Failed to update OIDC auto-provision setting", + "updateOidcSilentLoginDefaultFailed": "Failed to update silent OIDC login setting", "updatePasswordResetFailed": "Failed to update password reset setting", "sessionTimeoutRange2": "Session timeout must be between 1 and 720 hours", "sessionTimeoutSaved": "Session timeout saved", @@ -2246,7 +2343,27 @@ "importSelectFile": "Please select a file first", "importCompleted": "Import completed: {{total}} items imported, {{skipped}} skipped", "importFailed": "Import failed: {{error}}", - "importError": "Database import failed" + "importError": "Database import failed", + "sectionHostDefaults": "Host Defaults", + "hostDefaultsDesc": "Settings applied automatically when creating a new host. Individual hosts can still override these.", + "hostDefaultsSocks5": "SOCKS5 Proxy", + "hostDefaultsUseSocks5": "Enable SOCKS5 Proxy", + "hostDefaultsUseSocks5Desc": "Pre-fill SOCKS5 proxy on all new hosts", + "hostDefaultsSocks5Host": "Proxy Host / Port", + "hostDefaultsSocks5Username": "Proxy Username", + "hostDefaultsSocks5Password": "Proxy Password", + "hostDefaultsMetrics": "Host Metrics", + "hostDefaultsMetricsEnabled": "Enable Metrics", + "hostDefaultsMetricsEnabledDesc": "Collect CPU, memory, and other stats on new hosts by default", + "hostDefaultsStatusCheckEnabled": "Enable Status Check", + "hostDefaultsStatusCheckEnabledDesc": "Poll online/offline status on new hosts by default", + "hostDefaultsTerminal": "Terminal", + "hostDefaultsSessionLogging": "Session Logging", + "hostDefaultsSessionLoggingDesc": "Record terminal sessions on new hosts by default", + "hostDefaultsCommandHistory": "Command History", + "hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default", + "hostDefaultsSaved": "Host defaults saved", + "hostDefaultsSaveFailed": "Failed to save host defaults" }, "newUi": { "sidebar": { @@ -2305,7 +2422,13 @@ "splitTab": "Split Tab", "addToSplit": "Add to Split", "removeFromSplit": "Remove from Split", - "assignToPane": "Assign to pane" + "assignToPane": "Assign to pane", + "hotkeysTitle": "Keyboard Shortcuts", + "hotkeysSplitRight": "Toggle 2-way split", + "hotkeysSplitBelow": "Toggle 3-way split", + "hotkeysNavigatePane": "Navigate panes", + "hotkeysNextTab": "Next tab", + "hotkeysPrevTab": "Previous tab" }, "snippets": { "title": "Snippets", @@ -2377,7 +2500,33 @@ "shareLoadError": "Failed to load share data", "loading": "Loading...", "close": "Close", - "reorderFailed": "Failed to save snippet order" + "reorderFailed": "Failed to save snippet order", + "importExport": "Import / Export", + "exportBtn": "Export JSON", + "importBtn": "Import JSON", + "exportSuccess": "Snippets exported successfully", + "exportFailed": "Failed to export snippets", + "importTitle": "Import Snippets", + "importDescription": "Import snippets and folders from a JSON file exported by Termix", + "importDropOrClick": "Drop a JSON file here or click to browse", + "importSelectedFile": "Selected: {{name}}", + "importOverwrite": "Overwrite existing snippets with the same name and folder", + "importStartBtn": "Import", + "importSuccess": "Import complete: {{snippets}} snippet(s) added, {{updated}} updated, {{skipped}} skipped, {{folders}} folder(s) added", + "importFailed": "Failed to import snippets", + "importInvalidFile": "Invalid file: expected a JSON object with snippets or folders arrays", + "targetHostsLabel": "Target Hosts", + "targetHostsHint": "Assign hosts to run this snippet directly without an open terminal.", + "noHostsAvailable": "No hosts configured", + "clearTargetHosts": "Clear all", + "hasTargetHosts": "Has target hosts", + "runOnTargets": "Run on Targets", + "directRunSuccess": "Ran \"{{name}}\" on {{count}} host(s)", + "directRunPartialFail": "\"{{name}}\" failed on one or more hosts", + "executionResultTitle": "Execution Results: {{name}}", + "executionResultDescription": "Output from running the snippet on each target host.", + "executionSuccess": "Success", + "executionFailed": "Failed" }, "userProfile": { "storageModeLocal": "Browser", @@ -2402,6 +2551,7 @@ "versionLabel": "Version", "deleteAccount": "Delete Account", "deleteAccountDescription": "Permanently delete your account", + "changeServerDescription": "Switch to a different Termix backend server", "deleteButton": "Delete", "deleteAccountPermanent": "This action is permanent and cannot be undone.", "deleteAccountWarning": "All sessions, hosts, credentials, and settings will be permanently deleted.", @@ -2413,6 +2563,8 @@ "settingsTerminal": "Terminal", "commandAutocomplete": "Command Autocomplete", "commandAutocompleteDesc": "Show autocomplete while typing", + "terminalLinkBehavior": "Terminal Link Click", + "terminalLinkBehaviorDesc": "Default behavior when clicking links in the terminal", "historyTracking": "History Tracking", "historyTrackingDesc": "Track terminal commands", "commandPalette": "Command Palette", @@ -2426,6 +2578,10 @@ "showHostTagsDesc": "Display tags in host list", "hostTrayOnClick": "Click to Expand Host Actions", "hostTrayOnClickDesc": "Always show connection buttons; click to expand management options instead of hover", + "compactHostView": "Compact Host View", + "compactHostViewDesc": "Collapse each host to a single line showing only its name and address", + "statusColors": "Real Status Colors", + "statusColorsDesc": "Use green/red for online/offline status instead of the accent color", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover", "settingsNavigation": "Navigation", @@ -2604,6 +2760,13 @@ "arrowUp": "^", "arrowDown": "v", "arrowLeft": "<", - "arrowRight": ">" + "arrowRight": ">", + "quickKeys": "Quick Keys", + "editQuickKeys": "Edit quick keys", + "doneEditing": "Done", + "addSymbol": "Add", + "removeSymbol": "Remove", + "quickKeyPlaceholder": "Symbol", + "resetDefaults": "Reset" } } diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 6b531fa5b..a8ce37e5c 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1501,6 +1501,7 @@ export { updateSSHHost, wakeOnLan, bulkImportSSHHosts, + importSSHConfigHosts, discoverProxmoxGuests, bulkUpdateSSHHosts, deleteSSHHost, @@ -1637,6 +1638,21 @@ export async function registerUser( } } +export async function adminCreateUser( + username: string, + password: string, +): Promise> { + try { + const response = await authApi.post("/users/admin-create", { + username, + password, + }); + return response.data; + } catch (error) { + handleApiError(error, "admin create user"); + } +} + export async function loginUser( username: string, password: string, @@ -1914,6 +1930,8 @@ export { updateRegistrationAllowed, getOidcAutoProvision, updateOidcAutoProvision, + getOidcSilentLoginDefault, + updateOidcSilentLoginDefault, updatePasswordLoginAllowed, getPasswordResetAllowed, updatePasswordResetAllowed, @@ -1994,12 +2012,20 @@ export { } from "@/api/snippets-api"; // ============================================================================ -export type { UptimeInfo, RecentActivityItem } from "@/api/dashboard-api"; +export type { + UptimeInfo, + RecentActivityItem, + ServiceLink, +} from "@/api/dashboard-api"; export { getUptime, getRecentActivity, logActivity, resetRecentActivity, + getServiceLinks, + createServiceLink, + deleteServiceLink, + updateServiceLink, } from "@/api/dashboard-api"; // ============================================================================ diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index 18f326607..7f731eeed 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -15,6 +15,7 @@ import { LayoutPanelLeft, Plus, Minus, + Pencil, } from "lucide-react"; import { tabIcon } from "@/shell/tabUtils"; import type { Tab, TabType, SplitMode } from "@/types/ui-types"; @@ -35,6 +36,7 @@ export function TabBar({ onSplitTab, onAddToSplit, onRemoveFromSplit, + onRenameTab, }: { tabs: Tab[]; activeTabId: string; @@ -48,6 +50,7 @@ export function TabBar({ onSplitTab: (tabId: string, mode: SplitMode) => void; onAddToSplit: (tabId: string) => void; onRemoveFromSplit: (tabId: string) => void; + onRenameTab?: (tabId: string, newLabel: string) => void; }) { const { t } = useTranslation(); const [open, setOpen] = useState(true); @@ -58,6 +61,9 @@ export function TabBar({ const [contextPos, setContextPos] = useState<{ x: number; y: number } | null>( null, ); + const [renamingTabId, setRenamingTabId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const renameInputRef = useRef(null); const tabBarRef = useRef(null); const tabEls = useRef>(new Map()); @@ -165,6 +171,19 @@ export function TabBar({ return () => window.removeEventListener("mousedown", onDown); }, [contextTabId]); + useEffect(() => { + if (renamingTabId) { + setTimeout(() => renameInputRef.current?.focus(), 0); + } + }, [renamingTabId]); + + function commitRename() { + if (!renamingTabId) return; + const trimmed = renameValue.trim(); + if (trimmed) onRenameTab?.(renamingTabId, trimmed); + setRenamingTabId(null); + } + const dragIdx = tabs.findIndex((t) => t.id === dragTabId); const target = dragTargetIndex ?? dragIdx; @@ -288,8 +307,25 @@ export function TabBar({ )} {tabIcon(tab.type)} - {tab.type !== "dashboard" && tab.label} - {tab.type !== "dashboard" && ( + {tab.type !== "dashboard" && renamingTabId === tab.id ? ( + setRenameValue(e.target.value)} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(); + else if (e.key === "Escape") setRenamingTabId(null); + }} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="bg-transparent border-b border-accent-brand outline-none text-sm w-28 min-w-0" + style={{ fontWeight: "inherit" }} + /> + ) : ( + tab.type !== "dashboard" && tab.label + )} + {tab.type !== "dashboard" && renamingTabId !== tab.id && (
@@ -300,7 +336,7 @@ export function TabBar({ e.stopPropagation(); onRefreshTab(tab.id); }} - title="Refresh connection" + title={t("nav.refreshTab")} className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted" > @@ -456,13 +492,25 @@ export function TabBar({ }} > - Refresh connection + {t("nav.refreshTab")} )} +
{/* Split submenu */}
- Split + {t("terminal.split.splitTab")}
{SPLIT_MODES.filter((m) => m.id !== "none").map((mode) => ( ) : hasEmptySlot ? ( ) : null} @@ -514,7 +562,7 @@ export function TabBar({ }} > - Close tab + {t("nav.closeTab")}
); diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 117d0f82c..26b2a1c94 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -129,12 +129,18 @@ function TerminalTabContent({ label, isVisible, onCloseTab, + onRenameTab, + onOpenFileInEditor, + onOpenFileManager, }: { tab: Tab; host: Host; label: string; isVisible: boolean; onCloseTab?: (id: string) => void; + onRenameTab?: (tabId: string, newLabel: string) => void; + onOpenFileInEditor?: (filePath: string) => void; + onOpenFileManager?: (path?: string) => void; }) { const { previewTerminalTheme } = useTabsSafe(); const isMobile = useIsMobile(); @@ -157,7 +163,12 @@ function TerminalTabContent({ showTitle={false} splitScreen={false} onClose={() => onCloseTab?.(tab.id)} + onTitleChange={ + onRenameTab ? (title) => onRenameTab(tab.id, title) : undefined + } previewTheme={previewTerminalTheme} + onOpenFileInEditor={onOpenFileInEditor} + onOpenFileManager={onOpenFileManager} />
{isMobile && ( @@ -178,6 +189,9 @@ export function renderTabContent( onOpenTab?: (host: Host, type: TabType) => void, onCloseTab?: (id: string) => void, isVisible = true, + onOpenFileInEditor?: (host: Host, filePath: string) => void, + onOpenFileManager?: (host: Host, path?: string) => void, + onRenameTab?: (tabId: string, newLabel: string) => void, ) { const { host, label } = tab; @@ -205,6 +219,15 @@ export function renderTabContent( label={label} isVisible={isVisible} onCloseTab={onCloseTab} + onRenameTab={onRenameTab} + onOpenFileInEditor={ + onOpenFileInEditor + ? (fp) => onOpenFileInEditor(host, fp) + : undefined + } + onOpenFileManager={ + onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined + } /> ); @@ -216,7 +239,12 @@ export function renderTabContent( messageKey="fileManager.noHostSelected" /> ); - return ; + return ( + + ); case "docker": if (!host) diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index ae80dcc94..0c5b0c0d9 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -9,7 +9,7 @@ import { deleteUser, revokeAllUserSessions, createRole, - registerUser, + adminCreateUser, makeUserAdmin, removeAdminStatus, getRegistrationAllowed, @@ -28,6 +28,8 @@ import { updateGuacamoleSettings, getOidcAutoProvision, updateOidcAutoProvision, + getOidcSilentLoginDefault, + updateOidcSilentLoginDefault, getCommandHistoryEnabled, updateCommandHistoryEnabled, isElectron, @@ -36,7 +38,16 @@ import { import { getTailscaleSettings, updateTailscaleSettings, + getHostDefaults, + updateHostDefaults, + type HostDefaults, } from "@/api/settings-api"; +import { + getAcmeSslSettings, + updateAcmeSslSettings, + requestAcmeCertificate, + type AcmeSettings, +} from "@/api/acme-ssl-api"; import { getAdminSSOProviders, updateSSOProvider, @@ -58,7 +69,9 @@ import { getBasePath } from "@/lib/base-path"; import { AdminDatabaseSection, AdminGeneralSettingsSection, + AdminHostDefaultsSection, AdminSSOSection, + AdminSSLSection, } from "./AdminSettingsSections"; import { SSOProviderDialog } from "./SSOProviderDialog"; import { AdminApiKeysSection } from "./AdminApiKeysSection"; @@ -98,9 +111,11 @@ export function AdminSettingsPanel() { const [logLevel, setLogLevel] = useState("info"); const [tailscaleApiKey, setTailscaleApiKey] = useState(""); const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true); + const [hostDefaults, setHostDefaults] = useState({}); // SSO / auto-provision state const [oidcAutoProvision, setOidcAutoProvision] = useState(false); + const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false); const [ssoProviders, setSsoProviders] = useState([]); const [ssoDialogOpen, setSsoDialogOpen] = useState(false); const [ssoDialogProvider, setSsoDialogProvider] = @@ -155,6 +170,22 @@ export function AdminSettingsPanel() { const [exportLoading, setExportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false); + // ACME SSL state + const defaultAcmeSettings: AcmeSettings = { + enabled: false, + domain: "", + email: "", + challengeType: "http-webroot", + cloudflareToken: "", + lastIssuedAt: null, + certStatus: "none", + certExpiresAt: null, + }; + const [acmeSettings, setAcmeSettings] = + useState(defaultAcmeSettings); + const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState(""); + const [acmeRequesting, setAcmeRequesting] = useState(false); + const [users, setUsers] = useState([]); const [sessions, setSessions] = useState([]); const [roles, setRoles] = useState([]); @@ -225,6 +256,7 @@ export function AdminSettingsPanel() { level, guac, oidcProv, + oidcSilent, tailscale, cmdHistory, ] = await Promise.allSettled([ @@ -236,6 +268,7 @@ export function AdminSettingsPanel() { getLogLevel(), getGuacamoleSettings(), getOidcAutoProvision(), + getOidcSilentLoginDefault(), getTailscaleSettings(), getCommandHistoryEnabled(), ]); @@ -245,6 +278,8 @@ export function AdminSettingsPanel() { setAllowPasswordLogin(pwLogin.value.allowed); if (oidcProv.status === "fulfilled") setOidcAutoProvision(oidcProv.value.enabled); + if (oidcSilent.status === "fulfilled") + setOidcSilentLoginDefault(oidcSilent.value.enabled); if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value); if (timeout.status === "fulfilled") setSessionTimeout(String(timeout.value.timeoutHours)); @@ -266,6 +301,14 @@ export function AdminSettingsPanel() { } catch { // non-fatal } + + getHostDefaults() + .then((d) => setHostDefaults(d)) + .catch(() => {}); + + getAcmeSslSettings() + .then((s) => setAcmeSettings(s)) + .catch(() => {}); } async function loadSSOProviders() { @@ -281,6 +324,15 @@ export function AdminSettingsPanel() { setOpenSection((prev) => (prev === id ? null : id)); } + async function handleSaveHostDefaults() { + try { + await updateHostDefaults(hostDefaults); + toast.success(t("admin.hostDefaultsSaved")); + } catch { + toast.error(t("admin.hostDefaultsSaveFailed")); + } + } + async function handleToggleRegistration() { const newVal = !allowRegistration; setAllowRegistration(newVal); @@ -315,6 +367,17 @@ export function AdminSettingsPanel() { } } + async function handleToggleOidcSilentLoginDefault() { + const newVal = !oidcSilentLoginDefault; + setOidcSilentLoginDefault(newVal); + try { + await updateOidcSilentLoginDefault(newVal); + } catch { + setOidcSilentLoginDefault(!newVal); + toast.error(t("admin.updateOidcSilentLoginDefaultFailed")); + } + } + async function handleTogglePasswordReset() { const newVal = !allowPasswordReset; setAllowPasswordReset(newVal); @@ -444,6 +507,50 @@ export function AdminSettingsPanel() { } } + async function handleSaveAcmeSettings() { + try { + const payload: Parameters[0] = { + enabled: acmeSettings.enabled, + domain: acmeSettings.domain, + email: acmeSettings.email, + challengeType: acmeSettings.challengeType, + ...(cloudflareTokenDraft && { cloudflareToken: cloudflareTokenDraft }), + }; + const updated = await updateAcmeSslSettings(payload); + setAcmeSettings(updated); + setCloudflareTokenDraft(""); + toast.success(t("admin.sslSaved")); + } catch { + toast.error(t("admin.sslSaveFailed")); + } + } + + async function handleRequestAcmeCertificate() { + if (!acmeSettings.domain || !acmeSettings.email) { + toast.error(t("admin.sslRequiresDomain")); + return; + } + setAcmeRequesting(true); + try { + if (cloudflareTokenDraft) { + await updateAcmeSslSettings({ + domain: acmeSettings.domain, + email: acmeSettings.email, + challengeType: acmeSettings.challengeType, + cloudflareToken: cloudflareTokenDraft, + }); + setCloudflareTokenDraft(""); + } + const result = await requestAcmeCertificate(); + setAcmeSettings(result); + toast.success(t("admin.sslRequestCertSuccess")); + } catch (e) { + toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed"))); + } finally { + setAcmeRequesting(false); + } + } + function handleProviderSaved(saved: SSOProvider) { setSsoProviders((prev) => { const idx = prev.findIndex((p) => p.id === saved.id); @@ -467,7 +574,7 @@ export function AdminSettingsPanel() { } setCreateUserLoading(true); try { - await registerUser(newUsername.trim(), newPassword); + await adminCreateUser(newUsername.trim(), newPassword); toast.success(t("admin.createUserSuccess", { username: newUsername })); setCreateUserOpen(false); setNewUsername(""); @@ -708,6 +815,8 @@ export function AdminSettingsPanel() { handleTogglePasswordLogin={handleTogglePasswordLogin} oidcAutoProvision={oidcAutoProvision} handleToggleOidcAutoProvision={handleToggleOidcAutoProvision} + oidcSilentLoginDefault={oidcSilentLoginDefault} + handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault} allowPasswordReset={allowPasswordReset} handleTogglePasswordReset={handleTogglePasswordReset} commandHistoryEnabled={commandHistoryEnabled} @@ -789,6 +898,14 @@ export function AdminSettingsPanel() { createRoleLoading={createRoleLoading} /> + toggle("host-defaults")} + defaults={hostDefaults} + setDefaults={setHostDefaults} + handleSaveDefaults={handleSaveHostDefaults} + /> + toggle("database")} @@ -800,6 +917,18 @@ export function AdminSettingsPanel() { handleImportDatabase={handleImportDatabase} /> + toggle("ssl")} + settings={acmeSettings} + setSettings={setAcmeSettings} + cloudflareTokenDraft={cloudflareTokenDraft} + setCloudflareTokenDraft={setCloudflareTokenDraft} + requesting={acmeRequesting} + handleSave={handleSaveAcmeSettings} + handleRequest={handleRequestAcmeCertificate} + /> + toggle("api-keys")} diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index cae55cbef..5e9be46fa 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -2,18 +2,30 @@ import type { Dispatch, SetStateAction } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; +import { PasswordInput } from "@/components/password-input"; import { SettingRow } from "@/components/section-card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/select"; import { Database, + Lock, Pencil, Plus, RefreshCw, + Server, Settings, Shield, Trash2, } from "lucide-react"; import { AccordionSection, AdminToggle } from "./AdminSettingsShared"; import type { SSOProvider, SSOProviderType } from "@/types/index"; +import type { HostDefaults } from "@/api/settings-api"; +import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api"; type GeneralSettingsSectionProps = { open: boolean; @@ -24,6 +36,8 @@ type GeneralSettingsSectionProps = { handleTogglePasswordLogin: () => void; oidcAutoProvision: boolean; handleToggleOidcAutoProvision: () => void; + oidcSilentLoginDefault: boolean; + handleToggleOidcSilentLoginDefault: () => void; allowPasswordReset: boolean; handleTogglePasswordReset: () => void; commandHistoryEnabled: boolean; @@ -57,6 +71,8 @@ export function AdminGeneralSettingsSection({ handleTogglePasswordLogin, oidcAutoProvision, handleToggleOidcAutoProvision, + oidcSilentLoginDefault, + handleToggleOidcSilentLoginDefault, allowPasswordReset, handleTogglePasswordReset, commandHistoryEnabled, @@ -117,6 +133,15 @@ export function AdminGeneralSettingsSection({ onToggle={handleToggleOidcAutoProvision} /> + + + ); } + +type AdminHostDefaultsSectionProps = { + open: boolean; + onToggle: () => void; + defaults: HostDefaults; + setDefaults: Dispatch>; + handleSaveDefaults: () => void; +}; + +export function AdminHostDefaultsSection({ + open, + onToggle, + defaults, + setDefaults, + handleSaveDefaults, +}: AdminHostDefaultsSectionProps) { + const { t } = useTranslation(); + + return ( + } + open={open} + onToggle={onToggle} + > +
+ + {t("admin.hostDefaultsDesc")} + + +
+ + {t("admin.hostDefaultsSocks5")} + + + + setDefaults((p) => ({ ...p, useSocks5: !p.useSocks5 })) + } + /> + + {defaults.useSocks5 && ( +
+
+ +
+ + setDefaults((p) => ({ ...p, socks5Host: e.target.value })) + } + placeholder="127.0.0.1" + className="text-xs" + /> + + setDefaults((p) => ({ + ...p, + socks5Port: Number(e.target.value), + })) + } + placeholder="1080" + className="text-xs w-24 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
+
+ + + setDefaults((p) => ({ + ...p, + socks5Username: e.target.value, + })) + } + className="text-xs" + /> +
+
+ + + setDefaults((p) => ({ + ...p, + socks5Password: e.target.value, + })) + } + className="h-8 text-xs pr-8" + /> +
+
+ )} +
+ +
+ + {t("admin.hostDefaultsMetrics")} + + + + setDefaults((p) => ({ + ...p, + metricsEnabled: !(p.metricsEnabled ?? true), + })) + } + /> + + + + setDefaults((p) => ({ + ...p, + statusCheckEnabled: !(p.statusCheckEnabled ?? true), + })) + } + /> + +
+ +
+ + {t("admin.hostDefaultsTerminal")} + + + + setDefaults((p) => ({ + ...p, + enableSessionLogging: !(p.enableSessionLogging ?? true), + })) + } + /> + + + + setDefaults((p) => ({ + ...p, + enableCommandHistory: !(p.enableCommandHistory ?? true), + })) + } + /> + +
+ + +
+
+ ); +} + +const CERT_STATUS_STYLES: Record = { + none: "text-muted-foreground", + valid: "text-green-500", + expiring: "text-yellow-500", + expired: "text-destructive", +}; + +type AdminSSLSectionProps = { + open: boolean; + onToggle: () => void; + settings: AcmeSettings; + setSettings: Dispatch>; + cloudflareTokenDraft: string; + setCloudflareTokenDraft: Dispatch>; + requesting: boolean; + handleSave: () => void; + handleRequest: () => void; +}; + +export function AdminSSLSection({ + open, + onToggle, + settings, + setSettings, + cloudflareTokenDraft, + setCloudflareTokenDraft, + requesting, + handleSave, + handleRequest, +}: AdminSSLSectionProps) { + const { t } = useTranslation(); + + const certStatusLabel: Record = { + none: t("admin.sslCertStatusNone"), + valid: t("admin.sslCertStatusValid"), + expiring: t("admin.sslCertStatusExpiring"), + expired: t("admin.sslCertStatusExpired"), + }; + + return ( + } + open={open} + onToggle={onToggle} + > +
+ + {t("admin.sslDescription")}{" "} + + {t("admin.sslDocsLink")} + + + +
+
+ + {t("admin.sslCertStatus")} + + + {certStatusLabel[settings.certStatus]} + +
+ {settings.certExpiresAt && ( + + {t("admin.sslCertExpiresAt", { + date: new Date(settings.certExpiresAt).toLocaleDateString(), + })} + + )} + {settings.lastIssuedAt && ( + + {t("admin.sslLastIssued", { + date: new Date(settings.lastIssuedAt).toLocaleString(), + })} + + )} +
+ +
+ + + 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" + /> +
+ +
+ + + + {t("admin.sslChallengeTypeDesc")} + +
+ + {settings.challengeType === "dns-cloudflare" && ( +
+ + setCloudflareTokenDraft(e.target.value)} + placeholder={ + settings.cloudflareToken || + t("admin.sslCloudflareTokenPlaceholder") + } + className="text-xs h-8 pr-8" + /> + + {t("admin.sslCloudflareTokenDesc")} + +
+ )} + + + {t("admin.sslInfoNote")} + + +
+ + +
+
+
+ ); +} diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index 331854e91..a24cfd1de 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -5,6 +5,7 @@ import { Hammer, KeyRound, LayoutPanelLeft, + LogOut, Network, Play, Plug, @@ -14,12 +15,6 @@ import { User, Zap, } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/dropdown-menu"; import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types"; export type RailView = @@ -131,8 +126,6 @@ export function AppRail({ splitMode, username, isAdmin, - profileDropdownOpen, - onProfileDropdownChange, onRailClick, onOpenTab, onLogout, @@ -142,8 +135,6 @@ export function AppRail({ splitMode: SplitMode; username: string; isAdmin: boolean; - profileDropdownOpen: boolean; - onProfileDropdownChange: (open: boolean) => void; onRailClick: (view: RailView) => void; onOpenTab?: (type: TabType) => void; onLogout: () => void; @@ -182,7 +173,7 @@ export function AppRail({ return () => window.removeEventListener("hiddenRailTabsChanged", handler); }, []); - const railExpanded = pinned || hovered || profileDropdownOpen; + const railExpanded = pinned || hovered; const railButtons = buildRailButtons(splitMode, t, hiddenTabs); return ( @@ -293,50 +284,50 @@ export function AppRail({
))} +
+
- - - - - - - - Logout - - - + {username.charAt(0).toUpperCase() || "U"} +
+
+ + {username || "User"} + + + {isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")} + +
+
); diff --git a/src/ui/sidebar/ConnectionsPanel.tsx b/src/ui/sidebar/ConnectionsPanel.tsx index b9c19079e..78d10a1e6 100644 --- a/src/ui/sidebar/ConnectionsPanel.tsx +++ b/src/ui/sidebar/ConnectionsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { ExternalLink, Plug, Search, X } from "lucide-react"; +import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react"; import { getActiveSessions, deleteOpenTab, @@ -62,36 +62,65 @@ function ConnectionRow({ isLive, tabType, name, + hostName, subLabel, icon, onSwitch, onClose, switchTitle, faded, + onRename, + isDragging, }: { isActive?: boolean; isLive: boolean; tabType: string; name: string; + hostName?: string; subLabel: string; icon: React.ReactNode; onSwitch?: () => void; onClose: () => void; switchTitle?: string; faded?: boolean; + onRename?: (newLabel: string) => void; + isDragging?: boolean; }) { + const { t } = useTranslation(); + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(name); + + function startEdit(e: React.MouseEvent) { + e.stopPropagation(); + setEditValue(name); + setEditing(true); + } + + function commitEdit() { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== name && onRename) { + onRename(trimmed); + } + setEditing(false); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") commitEdit(); + if (e.key === "Escape") setEditing(false); + } + return (
e.key === "Enter" && onSwitch?.()} + role={onSwitch && !editing ? "button" : undefined} + tabIndex={onSwitch && !editing ? 0 : undefined} + onClick={!editing ? onSwitch : undefined} + onKeyDown={(e) => !editing && e.key === "Enter" && onSwitch?.()} className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${ faded ? "opacity-60" : "" - } ${ + } ${isDragging ? "opacity-30" : ""} ${ isActive ? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand" - : onSwitch + : onSwitch && !editing ? "hover:bg-muted/40 cursor-pointer" : "" }`} @@ -113,13 +142,26 @@ function ConnectionRow({ isLive ? "bg-green-500" : "bg-muted-foreground/30" }`} /> - - {name} - + {editing ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleKeyDown} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground" + autoFocus + /> + ) : ( + + {name} + + )}
+ {hostName && hostName !== name ? ( + + {hostName} ·{" "} + + ) : null} {subLabel}
- {switchTitle && onSwitch && ( + {editing ? ( + + ) : ( + onRename && ( + + + + + + {t("connections.rename")} + + + ) + )} + {switchTitle && onSwitch && !editing && ( + {!editing && ( + + )}
@@ -187,6 +264,8 @@ export function ConnectionsPanel({ onCloseTab, onReopenTab, onForgetBackground, + onRenameTab, + onReorderTabs, }: { tabs: Tab[]; activeTabId: string; @@ -199,6 +278,8 @@ export function ConnectionsPanel({ restoredSessionId: string | null, ) => void; onForgetBackground: (recordId: string) => void; + onRenameTab?: (tabId: string, newLabel: string) => void; + onReorderTabs?: (tabs: Tab[]) => void; }) { const { t } = useTranslation(); const [now, setNow] = useState(Date.now()); @@ -206,11 +287,17 @@ export function ConnectionsPanel({ const [search, setSearch] = useState(""); const pollTimerRef = useRef | null>(null); + // Drag-to-reorder state + const [dragTabId, setDragTabId] = useState(null); + const [dragOverTabId, setDragOverTabId] = useState(null); + const rowEls = useRef>(new Map()); + const dragStartY = useRef(0); + const didDragRef = useRef(false); + const openTabs = tabs.filter((tab) => CONNECTION_TAB_TYPES.includes(tab.type), ); - // Filter background records to only those not already open in the tab bar const openInstanceIds = new Set( tabs.map((t) => t.instanceId).filter(Boolean), ); @@ -220,9 +307,13 @@ export function ConnectionsPanel({ const q = search.trim().toLowerCase(); const filteredOpenTabs = q - ? openTabs.filter((tab) => - (tab.host?.name ?? tab.label).toLowerCase().includes(q), - ) + ? openTabs.filter((tab) => { + const displayName = tab.customLabel ?? tab.host?.name ?? tab.label; + return ( + displayName.toLowerCase().includes(q) || + (tab.host?.name ?? "").toLowerCase().includes(q) + ); + }) : openTabs; const filteredBackgroundTabs = q ? backgroundTabs.filter((r) => { @@ -257,6 +348,69 @@ export function ConnectionsPanel({ }; }, [refresh]); + // Global pointer listeners for drag reorder + useEffect(() => { + if (!dragTabId) return; + + function onPointerMove(e: PointerEvent) { + if (Math.abs(e.clientY - dragStartY.current) > 4) + didDragRef.current = true; + if (!didDragRef.current) return; + + // Find which row the pointer is over + let overTabId: string | null = null; + rowEls.current.forEach((el, id) => { + if (id === dragTabId) return; + const rect = el.getBoundingClientRect(); + if (e.clientY >= rect.top && e.clientY <= rect.bottom) { + overTabId = id; + } + }); + setDragOverTabId(overTabId); + } + + function onPointerUp(e: PointerEvent) { + if (didDragRef.current && dragTabId && onReorderTabs) { + // Find drop target + let targetTabId: string | null = null; + rowEls.current.forEach((el, id) => { + if (id === dragTabId) return; + const rect = el.getBoundingClientRect(); + if (e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetTabId = id; + } + }); + + if (targetTabId) { + const fromIdx = openTabs.findIndex((t) => t.id === dragTabId); + const toIdx = openTabs.findIndex((t) => t.id === targetTabId); + if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) { + const reordered = [...openTabs]; + reordered.splice(toIdx, 0, reordered.splice(fromIdx, 1)[0]); + const connectionSet = new Set(CONNECTION_TAB_TYPES as string[]); + const nonConnectionTabs = tabs.filter( + (t) => !connectionSet.has(t.type), + ); + onReorderTabs([...nonConnectionTabs, ...reordered]); + } + } + } + + setDragTabId(null); + setDragOverTabId(null); + setTimeout(() => { + didDragRef.current = false; + }, 0); + } + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + }, [dragTabId, openTabs, tabs, onReorderTabs]); + const sessionByInstanceId = new Map( activeSessions.map((s) => [s.tabInstanceId, s]), ); @@ -322,24 +476,59 @@ export function ConnectionsPanel({ ? formatDuration(now - liveSession.createdAt) : formatDuration(now - tab.openedAt); + const displayName = tab.customLabel ?? tab.host?.name ?? tab.label; + const hostName = tab.host?.name; + const isDraggingThis = dragTabId === tab.id; + const isDropTarget = dragOverTabId === tab.id && !isDraggingThis; + return ( - onSwitchToTab(tab.id)} - onClose={() => onCloseTab(tab.id)} - /> + ref={(el) => { + if (el) rowEls.current.set(tab.id, el); + else rowEls.current.delete(tab.id); + }} + onPointerDown={(e) => { + if (e.button !== 0) return; + dragStartY.current = e.clientY; + didDragRef.current = false; + setDragTabId(tab.id); + }} + className={`relative ${isDropTarget ? "border-t-2 border-accent-brand" : ""}`} + style={{ + cursor: dragTabId + ? isDraggingThis + ? "grabbing" + : "default" + : "grab", + }} + > + { + if (!didDragRef.current) onSwitchToTab(tab.id); + }} + onClose={() => onCloseTab(tab.id)} + onRename={ + onRenameTab + ? (newLabel) => onRenameTab(tab.id, newLabel) + : undefined + } + isDragging={isDraggingThis} + /> +
); })}
diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index 7d56d24fc..f8c639363 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -32,8 +32,9 @@ import { subscribeTunnelStatuses, connectTunnel, disconnectTunnel, + getUserInfo, } from "@/main-axios"; -import { getTailscaleDevices } from "@/api/settings-api"; +import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api"; import type { Host } from "@/types/ui-types"; import type { SSHHost, TunnelStatus } from "@/types"; import { useTabsSafe } from "@/shell/TabContext"; @@ -110,6 +111,13 @@ export function HostEditor({ const [tailscaleHasApiKey, setTailscaleHasApiKey] = useState(false); const [tailscaleLoading, setTailscaleLoading] = useState(false); const [connectingTunnel, setConnectingTunnel] = useState(null); + const [isOidcUser, setIsOidcUser] = useState(false); + + useEffect(() => { + getUserInfo() + .then((info) => setIsOidcUser(info.is_oidc)) + .catch(() => {}); + }, []); useEffect(() => { getSnippets() @@ -117,6 +125,13 @@ export function HostEditor({ .catch(() => {}); }, []); + useEffect(() => { + if (host) return; + getHostDefaults() + .then((d) => setForm(createHostEditorForm(null, d))) + .catch(() => {}); + }, [host]); + useEffect(() => { if (activeTab !== "tunnels") return; const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s)); @@ -267,8 +282,19 @@ export function HostEditor({ !!selectedCredential?.username && !form.overrideCredentialUsername } + onFocus={() => { + if (form.username === "root") setField("username", ""); + }} + onBlur={() => { + if (form.username === "") setField("username", "root"); + }} onChange={(e) => setField("username", e.target.value)} /> + {isOidcUser && ( +

+ {t("hosts.oidcUsernameHint")} +

+ )}
{authMethod === "password" && (
@@ -588,6 +614,18 @@ export function HostEditor({ onChange={(v) => setField("forceKeyboardInteractive", v)} /> + + setField("allowLegacyAlgorithms", v)} + /> + )} +
+ +

+ {t("hosts.backgroundImageDesc")} +

+ + setField("backgroundImage", e.target.value) + } + placeholder="https://example.com/image.jpg" + 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 font-mono" + /> +
+ {form.backgroundImage && ( +
+
+ + + {Math.round(form.backgroundImageOpacity * 100)}% + +
+ + setField("backgroundImageOpacity", v) + } + /> +
+ )}
@@ -976,6 +1052,34 @@ export function HostEditor({ onChange={(v) => setField("enableCommandHistory", v)} /> +
+ + +

+ {t("hosts.linkClickBehaviorDesc")} +

+
@@ -1553,6 +1657,7 @@ export function HostEditor({ setField={setField} setGuacField={setGuacField} host={host} + credentials={credentials} /> )} @@ -1562,6 +1667,7 @@ export function HostEditor({ setField={setField} setGuacField={setGuacField} host={host} + credentials={credentials} /> )} diff --git a/src/ui/sidebar/HostEditorData.ts b/src/ui/sidebar/HostEditorData.ts index b15c6c3c2..b96039f44 100644 --- a/src/ui/sidebar/HostEditorData.ts +++ b/src/ui/sidebar/HostEditorData.ts @@ -1,6 +1,7 @@ import { TERMINAL_THEMES } from "@/lib/terminal-themes"; import type { Host } from "@/types/ui-types"; import type { SSHHostData } from "@/types"; +import type { HostDefaults } from "@/api/settings-api"; type HostSocks5ProxyNode = NonNullable[number]; @@ -42,8 +43,12 @@ export function mapSnippetResponse( })); } -export function createHostEditorForm(host: Host | null) { - const rawTheme = host?.terminalConfig?.theme; +export function createHostEditorForm( + host: Host | null, + defaults?: HostDefaults, +) { + const d = host ? undefined : defaults; + const rawTheme = host?.terminalConfig?.theme ?? d?.theme; const normalizedTheme = !rawTheme || ["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes( @@ -57,7 +62,7 @@ export function createHostEditorForm(host: Host | null) { return { name: host?.name ?? "", ip: host?.ip ?? "", - username: host?.username ?? "", + username: host?.username ?? (host ? "" : "root"), sshPort: host?.sshPort ?? host?.port ?? 22, rdpPort: host?.rdpPort ?? 3389, vncPort: host?.vncPort ?? 5900, @@ -70,7 +75,9 @@ export function createHostEditorForm(host: Host | null) { : (host?.keyPassword ?? ""), keyType: host?.keyType ?? "auto", keySubTab: "paste" as "paste" | "upload", - credentialId: host?.credentialId ?? "", + credentialId: + host?.credentialId ?? + (d?.credentialId != null ? String(d.credentialId) : ""), overrideCredentialUsername: host?.overrideCredentialUsername ?? false, folder: host?.folder ?? "", tags: host?.tags ?? ([] as string[]), @@ -78,24 +85,29 @@ export function createHostEditorForm(host: Host | null) { notes: host?.notes ?? "", pin: host?.pin ?? false, macAddress: host?.macAddress ?? "", - useSocks5: host?.useSocks5 ?? false, - socks5Host: host?.socks5Host ?? "", - socks5Port: host?.socks5Port ?? 1080, - socks5Username: host?.socks5Username ?? "", - socks5Password: host?.socks5Password ?? "", + wolBroadcastAddress: host?.wolBroadcastAddress ?? "", + useSocks5: host?.useSocks5 ?? d?.useSocks5 ?? false, + socks5Host: host?.socks5Host ?? d?.socks5Host ?? "", + socks5Port: host?.socks5Port ?? d?.socks5Port ?? 1080, + socks5Username: host?.socks5Username ?? d?.socks5Username ?? "", + socks5Password: host?.socks5Password ?? d?.socks5Password ?? "", socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0 ? "chain" : "single") as "single" | "chain", socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[], enableTerminal: host?.enableTerminal ?? true, - enableSessionLogging: host?.enableSessionLogging ?? true, - enableCommandHistory: host?.enableCommandHistory ?? true, + enableSessionLogging: + host?.enableSessionLogging ?? d?.enableSessionLogging ?? true, + enableCommandHistory: + host?.enableCommandHistory ?? d?.enableCommandHistory ?? true, enableFileManager: host?.enableFileManager ?? false, + scpLegacy: host?.scpLegacy ?? false, enableDocker: host?.enableDocker ?? false, enableTmuxMonitor: host?.enableTmuxMonitor ?? false, enableProxmox: host?.enableProxmox ?? false, proxmoxConfig: host?.proxmoxConfig ?? { defaultCredentialId: null as number | null, + defaultAuthType: "password" as string, windowsPatterns: "win, windows", dockerPatterns: "docker", preferredPrefixes: "10., 192.168.", @@ -103,15 +115,16 @@ export function createHostEditorForm(host: Host | null) { enableTunnel: host?.enableTunnel ?? false, defaultPath: host?.defaultPath ?? "/", forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false, - fontSize: host?.terminalConfig?.fontSize ?? 14, + fontSize: host?.terminalConfig?.fontSize ?? d?.fontSize ?? 14, fontFamily: - host?.terminalConfig?.fontFamily ?? "Caskaydia Cove Nerd Font Mono", + host?.terminalConfig?.fontFamily ?? + d?.fontFamily ?? + "Caskaydia Cove Nerd Font Mono", theme: normalizedTheme, - cursorStyle: (host?.terminalConfig?.cursorStyle ?? "bar") as - | "block" - | "underline" - | "bar", - cursorBlink: host?.terminalConfig?.cursorBlink ?? true, + cursorStyle: (host?.terminalConfig?.cursorStyle ?? + d?.cursorStyle ?? + "bar") as "block" | "underline" | "bar", + cursorBlink: host?.terminalConfig?.cursorBlink ?? d?.cursorBlink ?? true, scrollback: host?.terminalConfig?.scrollback ?? 10000, letterSpacing: host?.terminalConfig?.letterSpacing ?? 0, lineHeight: host?.terminalConfig?.lineHeight ?? 1.0, @@ -139,6 +152,12 @@ export function createHostEditorForm(host: Host | null) { sudoPassword: host?.terminalConfig?.sudoPassword ?? "", keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60, keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5, + backgroundImage: host?.terminalConfig?.backgroundImage ?? "", + backgroundImageOpacity: + host?.terminalConfig?.backgroundImageOpacity ?? 0.15, + allowLegacyAlgorithms: host?.terminalConfig?.allowLegacyAlgorithms ?? true, + linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ?? + "default") as "default" | "confirm" | "direct", syntaxHighlighting: host?.terminalConfig?.syntaxHighlighting ?? true, syntaxHighlightingOptions: { logLevels: @@ -161,21 +180,23 @@ export function createHostEditorForm(host: Host | null) { ([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]), quickActions: host?.quickActions ?? ([] as { name: string; snippetId: string }[]), + rdpCredentialId: host?.rdpCredentialId ?? "", rdpUser: host?.rdpUser ?? "", rdpPassword: host?.rdpPassword ?? "", domain: host?.domain ?? "", security: host?.security ?? "", ignoreCert: host?.ignoreCert ?? false, + vncCredentialId: host?.vncCredentialId ?? "", vncPassword: host?.vncPassword ?? "", vncUser: host?.vncUser ?? "", telnetUser: host?.telnetUser ?? "", telnetPassword: host?.telnetPassword ?? "", guacamoleConfig: host?.guacamoleConfig ?? {}, statsConfig: host?.statsConfig ?? { - statusCheckEnabled: true, + statusCheckEnabled: d?.statusCheckEnabled ?? true, statusCheckInterval: 60, useGlobalStatusInterval: true, - metricsEnabled: true, + metricsEnabled: d?.metricsEnabled ?? true, metricsInterval: 30, useGlobalMetricsInterval: true, enabledWidgets: [ @@ -246,11 +267,13 @@ export function buildHostEditorPayload( overrideCredentialUsername: form.overrideCredentialUsername, notes: form.notes, macAddress: form.macAddress || null, + wolBroadcastAddress: form.wolBroadcastAddress || null, enableTerminal: form.enableTerminal, enableSessionLogging: form.enableSessionLogging, enableCommandHistory: form.enableCommandHistory, enableTunnel: form.enableTunnel, enableFileManager: form.enableFileManager, + scpLegacy: form.scpLegacy, enableDocker: form.enableDocker, enableTmuxMonitor: form.enableTmuxMonitor, enableProxmox: form.enableProxmox, @@ -276,11 +299,19 @@ export function buildHostEditorPayload( vncPort: Number(form.vncPort), telnetPort: Number(form.telnetPort), forceKeyboardInteractive: form.forceKeyboardInteractive, + rdpCredentialId: + protocols.enableRdp && form.rdpCredentialId + ? Number(form.rdpCredentialId) + : null, rdpUser: form.rdpUser || null, rdpPassword: form.rdpPassword || null, rdpDomain: form.domain || null, rdpSecurity: form.security || null, rdpIgnoreCert: form.ignoreCert, + vncCredentialId: + protocols.enableVnc && form.vncCredentialId + ? Number(form.vncCredentialId) + : null, vncPassword: form.vncPassword || null, vncUser: form.vncUser || null, telnetUser: form.telnetUser || null, @@ -326,6 +357,13 @@ export function buildHostEditorPayload( environmentVariables: form.environmentVariables, syntaxHighlighting: form.syntaxHighlighting, syntaxHighlightingOptions: form.syntaxHighlightingOptions, + backgroundImage: form.backgroundImage || null, + backgroundImageOpacity: Number(form.backgroundImageOpacity), + allowLegacyAlgorithms: form.allowLegacyAlgorithms, + linkClickBehavior: + form.linkClickBehavior !== "default" + ? form.linkClickBehavior + : undefined, } : null, }; diff --git a/src/ui/sidebar/HostEditorFeatureTabs.tsx b/src/ui/sidebar/HostEditorFeatureTabs.tsx index c6b7faa76..5bc3d3845 100644 --- a/src/ui/sidebar/HostEditorFeatureTabs.tsx +++ b/src/ui/sidebar/HostEditorFeatureTabs.tsx @@ -72,6 +72,7 @@ export function HostProxmoxTab({ const cfg = form.proxmoxConfig ?? { defaultCredentialId: null, + defaultAuthType: "password", windowsPatterns: "win, windows", dockerPatterns: "docker", preferredPrefixes: "10., 192.168.", @@ -106,6 +107,29 @@ export function HostProxmoxTab({ {form.enableProxmox && ( <> + + + setField("enableFileManager", v)} /> + + setField("scpLegacy", v)} + /> +
)} + {protocols.enableSsh && form.macAddress && ( +
+ + + setField("wolBroadcastAddress", e.target.value) + } + /> +

+ {t("hosts.wolBroadcastAddressDesc")} +

+
+ )}
diff --git a/src/ui/sidebar/HostEditorGuacamoleTabs.tsx b/src/ui/sidebar/HostEditorGuacamoleTabs.tsx index 9c04d7d9d..9285ba061 100644 --- a/src/ui/sidebar/HostEditorGuacamoleTabs.tsx +++ b/src/ui/sidebar/HostEditorGuacamoleTabs.tsx @@ -14,6 +14,7 @@ import { Shield, Terminal, Zap, + Cpu, } from "lucide-react"; import type { HostEditorForm } from "./HostEditorData"; @@ -42,11 +43,13 @@ export function HostEditorRdpTab({ setField, setGuacField, host, + credentials, }: { form: HostEditorForm; setField: HostEditorSetField; setGuacField: GuacFieldSetter; host?: Host | null; + credentials?: { id: string; name: string; username: string }[]; }) { const { t } = useTranslation(); @@ -77,11 +80,63 @@ export function HostEditorRdpTab({
+ } + > +
+
+ + setGuacField("guacd-hostname", e.target.value)} + /> +
+
+ + setGuacField("guacd-port", e.target.value)} + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+

+ {t("hosts.guac.guacdProxyDesc")} +

+
+
+ } >
+ {credentials && credentials.length > 0 && ( +
+ + +
+ )}
@@ -810,11 +882,13 @@ export function HostEditorVncTab({ setField, setGuacField, host, + credentials, }: { form: HostEditorForm; setField: HostEditorSetField; setGuacField: GuacFieldSetter; host?: Host | null; + credentials?: { id: string; name: string; username: string }[]; }) { const { t } = useTranslation(); @@ -845,11 +919,63 @@ export function HostEditorVncTab({
+ } + > +
+
+ + setGuacField("guacd-hostname", e.target.value)} + /> +
+
+ + setGuacField("guacd-port", e.target.value)} + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+

+ {t("hosts.guac.guacdProxyDesc")} +

+
+
+ } >
+ {credentials && credentials.length > 0 && ( +
+ + +
+ )}
+ } + > +
+
+ + setGuacField("guacd-hostname", e.target.value)} + /> +
+
+ + setGuacField("guacd-port", e.target.value)} + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+

+ {t("hosts.guac.guacdProxyDesc")} +

+
+
} diff --git a/src/ui/sidebar/HostShareModal.tsx b/src/ui/sidebar/HostShareModal.tsx index aa4e98889..bb5a54b33 100644 --- a/src/ui/sidebar/HostShareModal.tsx +++ b/src/ui/sidebar/HostShareModal.tsx @@ -90,7 +90,8 @@ export function HostShareModal({ if (!open) return null; - const hasCredential = !!host?.credentialId; + const hasCredential = + !!host?.credentialId || !!host?.rdpCredentialId || !!host?.vncCredentialId; return (
diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index 955f394eb..07c52122f 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -39,6 +39,7 @@ import { import { getSSHHosts, bulkImportSSHHosts, + importSSHConfigHosts, exportAllSSHHosts, } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios"; @@ -248,6 +249,9 @@ export function HostsPanel({ const [proxmoxDefaultCredentialId, setProxmoxDefaultCredentialId] = useState< number | null >(null); + const [proxmoxDefaultAuthType, setProxmoxDefaultAuthType] = useState< + string | undefined + >(undefined); const [proxmoxDefaultUsername, setProxmoxDefaultUsername] = useState< string | undefined >(undefined); @@ -267,6 +271,7 @@ export function HostsPanel({ }); const filterActive = Object.values(filterState).some((arr) => arr.length > 0); const fileInputRef = useRef(null); + const sshConfigInputRef = useRef(null); const importOverwriteRef = useRef(false); const allTags = [...new Set(rawHosts.flatMap((h) => h.tags ?? []))]; @@ -501,6 +506,42 @@ export function HostsPanel({ }} /> + { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; + try { + const text = await file.text(); + const result = await importSSHConfigHosts( + text, + importOverwriteRef.current, + ); + const hosts = await getSSHHosts(); + setRawHosts(hosts); + window.dispatchEvent(new CustomEvent("termix:hosts-changed")); + const msg = [ + result.success ? `${result.success} imported` : null, + result.updated ? `${result.updated} updated` : null, + result.failed ? `${result.failed} failed` : null, + ] + .filter(Boolean) + .join(", "); + toast.success(`${t("hosts.importSSHConfig")}: ${msg}`); + } catch (err: unknown) { + toast.error( + err instanceof Error + ? err.message + : "Failed to import SSH config", + ); + } + }} + /> +
diff --git a/src/ui/sidebar/QuickConnectPanel.tsx b/src/ui/sidebar/QuickConnectPanel.tsx index a9ab717a6..460e87d8a 100644 --- a/src/ui/sidebar/QuickConnectPanel.tsx +++ b/src/ui/sidebar/QuickConnectPanel.tsx @@ -1,8 +1,10 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react"; import { Input } from "@/components/input"; import type { Host } from "@/types/ui-types"; +import { getCredentials } from "@/api/credentials-api"; +import { mapCredentials } from "./HostManagerData"; interface QuickConnectPanelProps { onConnect: (host: Host, type: "terminal" | "files") => void; @@ -12,13 +14,23 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) { const { t } = useTranslation(); const [host, setHost] = useState(""); const [port, setPort] = useState("22"); - const [username, setUsername] = useState(""); + const [username, setUsername] = useState("root"); const [authType, setAuthType] = useState<"password" | "key" | "credential">( "password", ); const [password, setPassword] = useState(""); const [privateKey, setPrivateKey] = useState(""); const [showPassword, setShowPassword] = useState(false); + const [credentialId, setCredentialId] = useState(""); + const [credentials, setCredentials] = useState< + { id: string; name: string; username: string }[] + >([]); + + useEffect(() => { + getCredentials() + .then((res) => setCredentials(mapCredentials(res))) + .catch(() => {}); + }, []); const connect = (type: "terminal" | "files") => { if (!host || !username) return; @@ -31,6 +43,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) { authType, password: authType === "password" ? password : undefined, key: authType === "key" ? privateKey : undefined, + credentialId: authType === "credential" ? credentialId : undefined, folder: "", online: false, cpu: null, @@ -94,6 +107,12 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) { { + if (username === "root") setUsername(""); + }} + onBlur={() => { + if (username === "") setUsername("root"); + }} onChange={(e) => setUsername(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") connect("terminal"); @@ -172,12 +191,25 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) { - +
)}
diff --git a/src/ui/sidebar/SSOProviderDialog.tsx b/src/ui/sidebar/SSOProviderDialog.tsx index 12f3a99c5..3b25a8271 100644 --- a/src/ui/sidebar/SSOProviderDialog.tsx +++ b/src/ui/sidebar/SSOProviderDialog.tsx @@ -51,6 +51,7 @@ function emptyOidc(): OIDCProviderConfig { allowed_users: "", admin_group: "", group_claim: "", + ca_cert: "", }; } @@ -84,6 +85,7 @@ type OIDCFields = { allowed_users: string; admin_group: string; group_claim: string; + ca_cert: string; }; type LDAPFields = { @@ -175,6 +177,7 @@ export function SSOProviderDialog({ allowed_users: (config.allowed_users as string) ?? d.allowed_users, admin_group: (config.admin_group as string) ?? d.admin_group, group_claim: (config.group_claim as string) ?? "", + ca_cert: (config.ca_cert as string) ?? "", }); } } else { @@ -231,6 +234,7 @@ export function SSOProviderDialog({ allowed_users: oidc.allowed_users || undefined, admin_group: oidc.admin_group || undefined, group_claim: oidc.group_claim || undefined, + ca_cert: oidc.ca_cert || undefined, }; } @@ -531,6 +535,20 @@ function OIDCConfigFields({ className="text-xs" /> + + + {t("admin.oidcCaCertDesc")} + +