diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 33634911..8543927b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,8 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/swagger-ui-react": "^5.18.0", @@ -38,6 +40,7 @@ "eslint": "^10.4.0", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", + "jsdom": "^29.1.1", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0", "vite": "8.0.14", @@ -141,6 +144,57 @@ "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -402,6 +456,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.2", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", @@ -505,6 +572,146 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -679,6 +886,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2629,6 +2854,54 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2640,6 +2913,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3249,6 +3529,29 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/antd": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/antd/-/antd-6.4.3.tgz", @@ -3324,6 +3627,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3418,6 +3731,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -3715,6 +4038,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3848,6 +4185,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -3871,6 +4222,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3941,6 +4299,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3951,6 +4319,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", @@ -3990,6 +4365,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4663,6 +5051,19 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4881,6 +5282,13 @@ "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", "license": "MIT" }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -4933,6 +5341,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5350,6 +5809,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5369,6 +5838,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5639,6 +6115,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5743,6 +6232,28 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -6165,6 +6676,16 @@ "node": ">=0.10" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -6240,6 +6761,19 @@ ], "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6568,6 +7102,13 @@ "react-dom": ">=16.8.0 <20" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -6627,6 +7168,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -6647,6 +7208,32 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-sitter": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", @@ -6796,6 +7383,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unraw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", @@ -7067,6 +7664,19 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-tree-sitter": { "version": "0.24.5", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz", @@ -7074,6 +7684,41 @@ "license": "MIT", "optional": true }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7153,6 +7798,23 @@ "repeat-string": "^1.5.2" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 66796ef9..aecf346a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,8 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/swagger-ui-react": "^5.18.0", @@ -50,6 +52,7 @@ "eslint": "^10.4.0", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", + "jsdom": "^29.1.1", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0", "vite": "8.0.14", diff --git a/frontend/src/components/PromptModal.tsx b/frontend/src/components/feedback/PromptModal.tsx similarity index 100% rename from frontend/src/components/PromptModal.tsx rename to frontend/src/components/feedback/PromptModal.tsx diff --git a/frontend/src/components/TextModal.tsx b/frontend/src/components/feedback/TextModal.tsx similarity index 100% rename from frontend/src/components/TextModal.tsx rename to frontend/src/components/feedback/TextModal.tsx diff --git a/frontend/src/components/feedback/index.ts b/frontend/src/components/feedback/index.ts new file mode 100644 index 00000000..f24deac9 --- /dev/null +++ b/frontend/src/components/feedback/index.ts @@ -0,0 +1,2 @@ +export { default as PromptModal } from './PromptModal'; +export { default as TextModal } from './TextModal'; diff --git a/frontend/src/components/DateTimePicker.css b/frontend/src/components/form/DateTimePicker.css similarity index 100% rename from frontend/src/components/DateTimePicker.css rename to frontend/src/components/form/DateTimePicker.css diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/form/DateTimePicker.tsx similarity index 100% rename from frontend/src/components/DateTimePicker.tsx rename to frontend/src/components/form/DateTimePicker.tsx diff --git a/frontend/src/components/HeaderMapEditor.tsx b/frontend/src/components/form/HeaderMapEditor.tsx similarity index 98% rename from frontend/src/components/HeaderMapEditor.tsx rename to frontend/src/components/form/HeaderMapEditor.tsx index c630c851..86d769c4 100644 --- a/frontend/src/components/HeaderMapEditor.tsx +++ b/frontend/src/components/form/HeaderMapEditor.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Input, Space } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; -import InputAddon from '@/components/InputAddon'; +import { InputAddon } from '@/components/ui'; // Reusable header-map editor. Handles the two wire shapes Xray uses for // HTTP-style header maps: diff --git a/frontend/src/components/JsonEditor.css b/frontend/src/components/form/JsonEditor.css similarity index 100% rename from frontend/src/components/JsonEditor.css rename to frontend/src/components/form/JsonEditor.css diff --git a/frontend/src/components/JsonEditor.tsx b/frontend/src/components/form/JsonEditor.tsx similarity index 100% rename from frontend/src/components/JsonEditor.tsx rename to frontend/src/components/form/JsonEditor.tsx diff --git a/frontend/src/components/form/index.ts b/frontend/src/components/form/index.ts new file mode 100644 index 00000000..9f3e3713 --- /dev/null +++ b/frontend/src/components/form/index.ts @@ -0,0 +1,3 @@ +export { default as DateTimePicker } from './DateTimePicker'; +export { default as JsonEditor } from './JsonEditor'; +export { default as HeaderMapEditor } from './HeaderMapEditor'; diff --git a/frontend/src/components/InfinityIcon.tsx b/frontend/src/components/ui/InfinityIcon.tsx similarity index 100% rename from frontend/src/components/InfinityIcon.tsx rename to frontend/src/components/ui/InfinityIcon.tsx diff --git a/frontend/src/components/InputAddon.css b/frontend/src/components/ui/InputAddon.css similarity index 100% rename from frontend/src/components/InputAddon.css rename to frontend/src/components/ui/InputAddon.css diff --git a/frontend/src/components/InputAddon.tsx b/frontend/src/components/ui/InputAddon.tsx similarity index 100% rename from frontend/src/components/InputAddon.tsx rename to frontend/src/components/ui/InputAddon.tsx diff --git a/frontend/src/components/SettingListItem.css b/frontend/src/components/ui/SettingListItem.css similarity index 100% rename from frontend/src/components/SettingListItem.css rename to frontend/src/components/ui/SettingListItem.css diff --git a/frontend/src/components/SettingListItem.tsx b/frontend/src/components/ui/SettingListItem.tsx similarity index 100% rename from frontend/src/components/SettingListItem.tsx rename to frontend/src/components/ui/SettingListItem.tsx diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 00000000..1e8121b5 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,3 @@ +export { default as InputAddon } from './InputAddon'; +export { default as InfinityIcon } from './InfinityIcon'; +export { default as SettingListItem } from './SettingListItem'; diff --git a/frontend/src/components/LazyMount.tsx b/frontend/src/components/utility/LazyMount.tsx similarity index 100% rename from frontend/src/components/LazyMount.tsx rename to frontend/src/components/utility/LazyMount.tsx diff --git a/frontend/src/components/utility/index.ts b/frontend/src/components/utility/index.ts new file mode 100644 index 00000000..6a3e0176 --- /dev/null +++ b/frontend/src/components/utility/index.ts @@ -0,0 +1 @@ +export { default as LazyMount } from './LazyMount'; diff --git a/frontend/src/components/Sparkline.css b/frontend/src/components/viz/Sparkline.css similarity index 100% rename from frontend/src/components/Sparkline.css rename to frontend/src/components/viz/Sparkline.css diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/viz/Sparkline.tsx similarity index 100% rename from frontend/src/components/Sparkline.tsx rename to frontend/src/components/viz/Sparkline.tsx diff --git a/frontend/src/components/viz/index.ts b/frontend/src/components/viz/index.ts new file mode 100644 index 00000000..1b402935 --- /dev/null +++ b/frontend/src/components/viz/index.ts @@ -0,0 +1 @@ +export { default as Sparkline } from './Sparkline'; diff --git a/frontend/src/components/AppSidebar.css b/frontend/src/layouts/AppSidebar.css similarity index 100% rename from frontend/src/components/AppSidebar.css rename to frontend/src/layouts/AppSidebar.css diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx similarity index 100% rename from frontend/src/components/AppSidebar.tsx rename to frontend/src/layouts/AppSidebar.tsx diff --git a/frontend/src/components/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx similarity index 100% rename from frontend/src/components/FinalMaskForm.tsx rename to frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx diff --git a/frontend/src/lib/xray/forms/transport/index.ts b/frontend/src/lib/xray/forms/transport/index.ts new file mode 100644 index 00000000..d2ab3e39 --- /dev/null +++ b/frontend/src/lib/xray/forms/transport/index.ts @@ -0,0 +1 @@ +export { default as FinalMaskForm } from './FinalMaskForm'; diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index a30cf81f..314c125b 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -2,7 +2,7 @@ import { Base64, Wireguard } from '@/utils'; import type { Inbound } from '@/schemas/api/inbound'; import type { VlessClient } from '@/schemas/protocols/inbound/vless'; -import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess'; +import type { VmessSecurity } from '@/schemas/protocols/shared/vmess'; import type { WireguardInboundPeer, WireguardInboundSettings, diff --git a/frontend/src/pages/api-docs/ApiDocsPage.tsx b/frontend/src/pages/api-docs/ApiDocsPage.tsx index 8841c707..d2e90c0e 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.tsx +++ b/frontend/src/pages/api-docs/ApiDocsPage.tsx @@ -4,7 +4,7 @@ import SwaggerUI from 'swagger-ui-react'; import 'swagger-ui-react/swagger-ui.css'; import { useTheme } from '@/hooks/useTheme'; -import AppSidebar from '@/components/AppSidebar'; +import AppSidebar from '@/layouts/AppSidebar'; import './ApiDocsPage.css'; const basePath = window.X_UI_BASE_PATH || ''; diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index d86e44a9..a317119a 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -7,7 +7,7 @@ import type { Dayjs } from 'dayjs'; import { RandomUtil, SizeFormatter } from '@/utils'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; -import DateTimePicker from '@/components/DateTimePicker'; +import { DateTimePicker } from '@/components/form'; import { useClients, type InboundOption } from '@/hooks/useClients'; import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client'; diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 4f1f575e..cc953285 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -20,7 +20,7 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import { HttpUtil, RandomUtil } from '@/utils'; -import DateTimePicker from '@/components/DateTimePicker'; +import { DateTimePicker } from '@/components/form'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client'; diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index f422064e..fd4cb7d8 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -7,7 +7,7 @@ import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import { isPostQuantumLink } from '@/lib/xray/inbound-link'; -import QrPanel from '@/pages/inbounds/QrPanel'; +import { QrPanel } from '@/pages/inbounds/qr'; import './ClientInfoModal.css'; const PROTOCOL_COLORS: Record = { diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx index 0cd9e56c..da6b11e1 100644 --- a/frontend/src/pages/clients/ClientQrModal.tsx +++ b/frontend/src/pages/clients/ClientQrModal.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Collapse, Modal, Spin } from 'antd'; import { HttpUtil } from '@/utils'; import { isPostQuantumLink } from '@/lib/xray/inbound-link'; -import QrPanel from '@/pages/inbounds/QrPanel'; +import { QrPanel } from '@/pages/inbounds/qr'; import type { ClientRecord } from '@/hooks/useClients'; interface SubSettings { diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index fc476973..44ca73e7 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -51,10 +51,10 @@ import { useWebSocket } from '@/hooks/useWebSocket'; import { useClients } from '@/hooks/useClients'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; -import AppSidebar from '@/components/AppSidebar'; +import AppSidebar from '@/layouts/AppSidebar'; import { IntlUtil, SizeFormatter } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; -import LazyMount from '@/components/LazyMount'; +import { LazyMount } from '@/components/utility'; const ClientFormModal = lazy(() => import('./ClientFormModal')); const ClientInfoModal = lazy(() => import('./ClientInfoModal')); const ClientQrModal = lazy(() => import('./ClientQrModal')); diff --git a/frontend/src/pages/groups/GroupsPage.tsx b/frontend/src/pages/groups/GroupsPage.tsx index 12690c0f..4b1882cd 100644 --- a/frontend/src/pages/groups/GroupsPage.tsx +++ b/frontend/src/pages/groups/GroupsPage.tsx @@ -42,8 +42,8 @@ import { usePageTitle } from '@/hooks/usePageTitle'; import { useClients } from '@/hooks/useClients'; import { HttpUtil } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; -import AppSidebar from '@/components/AppSidebar'; -import LazyMount from '@/components/LazyMount'; +import AppSidebar from '@/layouts/AppSidebar'; +import { LazyMount } from '@/components/utility'; import { keys } from '@/api/queryKeys'; import { ClientRecordSchema, diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx deleted file mode 100644 index 609717bf..00000000 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ /dev/null @@ -1,3129 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import dayjs from 'dayjs'; -import { - Button, - Card, - Checkbox, - Divider, - Empty, - Form, - Input, - InputNumber, - Modal, - Radio, - Select, - Space, - Switch, - Tabs, - Tooltip, - Typography, - message, -} from 'antd'; -import { - ArrowDownOutlined, - ArrowUpOutlined, - DeleteOutlined, - MinusOutlined, - PlusOutlined, - ReloadOutlined, -} from '@ant-design/icons'; - -import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; -import { - rawInboundToFormValues, - formValuesToWirePayload, - pruneEmpty, - normalizeSniffing, - normalizeClients, - dropLegacyOptionalEmpties, -} from '@/lib/xray/inbound-form-adapter'; -import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; -import { - canEnableReality, - canEnableStream, - canEnableTls, - isSS2022, -} from '@/lib/xray/protocol-capabilities'; -import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks'; -import { getRandomRealityTarget } from '@/models/reality-targets'; -import { - InboundFormBaseSchema, - InboundFormSchema, - type FallbackRow, - type InboundFormValues, -} from '@/schemas/forms/inbound-form'; -import { antdRule } from '@/utils/zodForm'; -import { - ALPN_OPTION, - Address_Port_Strategy, - DOMAIN_STRATEGY_OPTION, - Protocols, - SNIFFING_OPTION, - TCP_CONGESTION_OPTION, - TLS_CIPHER_OPTION, - TLS_VERSION_OPTION, - USAGE_OPTION, - UTLS_FINGERPRINT, -} from '@/schemas/primitives'; -import { - HappyEyeballsSchema, - SockoptStreamSettingsSchema, -} from '@/schemas/protocols/stream/sockopt'; -import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria'; -import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; -import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; -import { SniffingSchema } from '@/schemas/primitives/sniffing'; -import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp'; -import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp'; -import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws'; -import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc'; -import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade'; -import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp'; -import DateTimePicker from '@/components/DateTimePicker'; -import FinalMaskForm from '@/components/FinalMaskForm'; -import HeaderMapEditor from '@/components/HeaderMapEditor'; -import HysteriaMasqueradeForm from '@/components/HysteriaMasqueradeForm'; -import InputAddon from '@/components/InputAddon'; -import JsonEditor from '@/components/JsonEditor'; -import './InboundFormModal.css'; -import type { FormInstance } from 'antd'; -import type { NamePath } from 'antd/es/form/interface'; - -const { TextArea } = Input; -import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; -import type { NodeRecord } from '@/api/queries/useNodesQuery'; - -// Pattern A rewrite of InboundFormModal. Built as a sibling file so the -// build stays green while the rewrite progresses section by section. -// InboundsPage continues to render the old InboundFormModal.tsx until the -// atomic swap at the end (Core Decision 7). - -const { Text } = Typography; - -// Sub-editor for one slice of the form (settings, streamSettings, sniffing). -// Holds a local text buffer so the user can type freely; on every keystroke -// we try to JSON.parse and forward the result to form state. Invalid JSON -// is held in the buffer until the next valid moment — no panic on partial -// input. The buffer seeds once on mount; the modal's destroyOnHidden makes -// each open a fresh editor instance, so we don't need to re-sync on outer -// form changes. -function AdvancedSliceEditor({ - form, - path, - wrapKey, - minHeight, - maxHeight, -}: { - form: FormInstance; - path: NamePath; - // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so - // the JSON the user sees matches the wire shape's slice envelope (e.g. - // `{ "settings": { ... } }`). Edits unwrap the outer key before writing - // back to the form. Mirrors the legacy modal's wrappedConfigValue. - wrapKey?: string; - minHeight?: string; - maxHeight?: string; -}) { - const serialize = (value: unknown): string => { - const inner = value ?? {}; - return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2); - }; - - // preserve: true so useWatch returns the full subtree from the form - // store — without it, useWatch goes through getFieldsValue() which - // filters out unregistered fields. Slices like `settings` would lose - // their `clients` / `fallbacks` sub-trees because those aren't bound - // to any Form.Item. - const watched = Form.useWatch(path, { form, preserve: true }); - const lastEmitRef = useRef(''); - const [text, setText] = useState(() => { - const initial = serialize(form.getFieldValue(path)); - lastEmitRef.current = initial; - return initial; - }); - - useEffect(() => { - const formStr = serialize(watched); - if (formStr === lastEmitRef.current) return; - setText(formStr); - lastEmitRef.current = formStr; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [watched, wrapKey]); - - return ( - { - setText(next); - try { - const parsed = JSON.parse(next); - const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record)[wrapKey] ?? {} - : parsed; - form.setFieldValue(path, toWrite); - lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2); - } catch { - // invalid JSON; keep buffer, don't push to form - } - }} - /> - ); -} - -// The "All" editor shows the full inbound JSON in one editor: top-level -// connection fields plus the three nested sub-objects (settings, -// streamSettings, sniffing). Edits round-trip back to the form's slices, -// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity -// works the same way as AdvancedSliceEditor: useWatch on the slices we -// care about, lastEmitRef as the "we wrote this" guard. -function AdvancedAllEditor({ - form, - streamEnabled, -}: { - form: FormInstance; - streamEnabled: boolean; -}) { - // preserve: true — default useWatch returns only registered fields, so - // sub-trees we never bound (settings.clients/fallbacks, sniffing - // defaults, etc.) wouldn't show up. preserve switches the read to - // getFieldsValue(true) which returns the full form store. - const wListen = Form.useWatch('listen', { form, preserve: true }); - const wPort = Form.useWatch('port', { form, preserve: true }); - const wProtocol = Form.useWatch('protocol', { form, preserve: true }); - const wTag = Form.useWatch('tag', { form, preserve: true }); - const wSettings = Form.useWatch('settings', { form, preserve: true }); - const wSniffing = Form.useWatch('sniffing', { form, preserve: true }); - const wStream = Form.useWatch('streamSettings', { form, preserve: true }); - - const serialize = () => { - // Apply the same prune/normalize as the wire payload so the JSON - // shown here is what the panel actually POSTs (no empty defaults, - // disabled sniffing as { enabled: false }, finalmask dropped when - // there are no masks). - const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record; - if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) { - settingsView.clients = normalizeClients(wProtocol, settingsView.clients); - } - const streamView = streamEnabled - ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record) - : undefined; - dropLegacyOptionalEmpties(settingsView, streamView); - const out: Record = { - listen: wListen ?? '', - port: wPort ?? 0, - protocol: wProtocol ?? '', - tag: wTag ?? '', - settings: settingsView, - sniffing: normalizeSniffing(wSniffing as Parameters[0]), - }; - if (streamView) out.streamSettings = streamView; - return JSON.stringify(out, null, 2); - }; - - const lastEmitRef = useRef(''); - const [text, setText] = useState(() => { - const initial = serialize(); - lastEmitRef.current = initial; - return initial; - }); - - useEffect(() => { - const formStr = serialize(); - if (formStr === lastEmitRef.current) return; - setText(formStr); - lastEmitRef.current = formStr; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]); - - return ( - { - setText(next); - let parsed: Record; - try { - parsed = JSON.parse(next) as Record; - } catch { - return; - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; - if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen); - if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) { - form.setFieldValue('port', parsed.port); - } - if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol); - if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag); - if (parsed.settings && typeof parsed.settings === 'object') { - form.setFieldValue('settings', parsed.settings); - } - if (parsed.sniffing && typeof parsed.sniffing === 'object') { - form.setFieldValue('sniffing', parsed.sniffing); - } - if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') { - form.setFieldValue('streamSettings', parsed.streamSettings); - } - lastEmitRef.current = next; - }} - /> - ); -} - -const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); -const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const; -const NODE_ELIGIBLE_PROTOCOLS = new Set([ - Protocols.VLESS, - Protocols.VMESS, - Protocols.TROJAN, - Protocols.SHADOWSOCKS, - Protocols.HYSTERIA, - Protocols.WIREGUARD, -]); - -interface InboundFormModalProps { - open: boolean; - onClose: () => void; - onSaved: () => void; - mode: 'add' | 'edit'; - dbInbound: DBInbound | null; - dbInbounds: DBInbound[]; - availableNodes?: NodeRecord[]; -} - -function buildAddModeValues(): InboundFormValues { - const settings = createDefaultInboundSettings('vless') ?? undefined; - return rawInboundToFormValues({ - protocol: 'vless', - settings, - streamSettings: { - network: 'tcp', - security: 'none', - tcpSettings: TcpStreamSettingsSchema.parse({ header: { type: 'none' } }), - }, - sniffing: SniffingSchema.parse({}), - port: RandomUtil.randomInteger(10000, 60000), - listen: '', - tag: '', - enable: true, - trafficReset: 'never', - }); -} - -export default function InboundFormModal({ - open, - onClose, - onSaved, - mode, - dbInbound, - dbInbounds, - availableNodes, -}: InboundFormModalProps) { - const { t } = useTranslation(); - const [messageApi, messageContextHolder] = message.useMessage(); - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - const fallbackKeyRef = useRef(0); - const [fallbacks, setFallbacks] = useState([]); - - const selectableNodes = (availableNodes || []).filter((n) => n.enable); - const protocol = (Form.useWatch('protocol', form) ?? '') as string; - const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol); - const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false; - const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? ''; - const ssMethod = Form.useWatch(['settings', 'method'], form); - const isSSWith2022 = isSS2022({ - protocol, - settings: typeof ssMethod === 'string' ? { method: ssMethod } : {}, - }); - const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false; - const network = Form.useWatch(['streamSettings', 'network'], form) ?? ''; - const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none'; - const streamEnabled = canEnableStream({ protocol }); - const isFallbackHost = - (protocol === Protocols.VLESS || protocol === Protocols.TROJAN) - && network === 'tcp' - && (security === 'tls' || security === 'reality'); - - const fallbackChildOptions = (dbInbounds || []) - .filter((ib) => ib.id !== dbInbound?.id) - .map((ib) => ({ - label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, - value: ib.id, - })); - - const loadFallbacks = async (masterId: number | null) => { - if (!masterId) { - setFallbacks([]); - return; - } - const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`); - if (!msg?.success || !Array.isArray(msg.obj)) { - setFallbacks([]); - return; - } - setFallbacks( - (msg.obj as { - childId: number; - name?: string; - alpn?: string; - path?: string; - dest?: string; - xver?: number; - }[]) - .map((r) => ({ - rowKey: `fb-${++fallbackKeyRef.current}`, - childId: r.childId, - name: r.name || '', - alpn: r.alpn || '', - path: r.path || '', - dest: r.dest || '', - xver: r.xver || 0, - })), - ); - }; - - const saveFallbacks = async (masterId: number) => { - if (!masterId) return true; - const payload = { - fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({ - childId: c.childId, - name: c.name, - alpn: c.alpn, - path: c.path, - dest: c.dest, - xver: Number(c.xver) || 0, - sortOrder: i, - })), - }; - const msg = await HttpUtil.post( - `/panel/api/inbounds/${masterId}/fallbacks`, - payload, - { headers: { 'Content-Type': 'application/json' } }, - ); - return !!msg?.success; - }; - - // Derive a fallback row's SNI / ALPN / Path / xver from a child - // inbound's streamSettings — what the legacy panel auto-filled when an - // operator wired a fallback target. SNI/ALPN come straight off the - // child's TLS block; path depends on the child's transport (ws/grpc - // /httpupgrade carry an explicit path; tcp/kcp/xhttp have no path of - // their own). xver stays 0 unless the child explicitly opts in via - // PROXY-protocol sockopt. - const deriveFallbackDefaults = (childId: number): Partial => { - const child = (dbInbounds || []).find((ib) => ib.id === childId); - if (!child) return {}; - const stream = coerceInboundJsonField(child.streamSettings); - const tls = (stream.tlsSettings as Record | undefined) ?? {}; - const network = typeof stream.network === 'string' ? stream.network : ''; - const sni = typeof tls.serverName === 'string' ? tls.serverName : ''; - const alpnArr = Array.isArray(tls.alpn) ? tls.alpn : []; - const alpn = alpnArr.filter((v) => typeof v === 'string').join(','); - let path = ''; - if (network === 'ws') { - const ws = (stream.wsSettings as Record | undefined) ?? {}; - if (typeof ws.path === 'string') path = ws.path; - } else if (network === 'grpc') { - const grpc = (stream.grpcSettings as Record | undefined) ?? {}; - if (typeof grpc.serviceName === 'string') path = grpc.serviceName; - } else if (network === 'httpupgrade') { - const hu = (stream.httpupgradeSettings as Record | undefined) ?? {}; - if (typeof hu.path === 'string') path = hu.path; - } else if (network === 'xhttp') { - const xh = (stream.xhttpSettings as Record | undefined) ?? {}; - if (typeof xh.path === 'string') path = xh.path; - } - return { name: sni, alpn, path, xver: 0 }; - }; - - const addFallback = () => { - setFallbacks((prev) => [...prev, { - rowKey: `fb-${++fallbackKeyRef.current}`, - childId: null, - name: '', - alpn: '', - path: '', - dest: '', - xver: 0, - }]); - }; - - const updateFallback = (rowKey: string, patch: Partial) => { - setFallbacks((prev) => prev.map((r) => { - if (r.rowKey !== rowKey) return r; - // When the picker selects a new child inbound and the row hasn't - // been hand-edited yet (sni/alpn/path/dest all blank, xver = 0), - // pull the SNI/ALPN/Path defaults off that child. Operators who - // intentionally typed values keep them — we only fill the empties. - if (typeof patch.childId === 'number' && patch.childId !== r.childId) { - const isPristine = !r.name && !r.alpn && !r.path && !r.dest && r.xver === 0; - if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) }; - } - return { ...r, ...patch }; - })); - }; - - const removeFallback = (idx: number) => { - setFallbacks((prev) => prev.filter((_, i) => i !== idx)); - }; - - // Move a fallback row up/down by swapping adjacent indices. The order - // is persisted via the fallback row's sortOrder (rebuilt by index on - // save), so reordering survives reloads. - const moveFallback = (idx: number, direction: -1 | 1) => { - setFallbacks((prev) => { - const target = idx + direction; - if (target < 0 || target >= prev.length) return prev; - const next = prev.slice(); - [next[idx], next[target]] = [next[target], next[idx]]; - return next; - }); - }; - - // One-shot: add a fresh fallback row for every eligible inbound (i.e. - // every option in fallbackChildOptions) that is not already wired up. - // Convenient for operators who want catch-all routing to every host - // they manage on the panel. - const addAllFallbacks = () => { - setFallbacks((prev) => { - const alreadyHave = new Set(prev.map((r) => r.childId)); - const additions = fallbackChildOptions - .filter((opt) => !alreadyHave.has(opt.value)) - .map((opt) => { - const derived = deriveFallbackDefaults(opt.value); - return { - rowKey: `fb-${++fallbackKeyRef.current}`, - childId: opt.value, - name: derived.name ?? '', - alpn: derived.alpn ?? '', - path: derived.path ?? '', - dest: '', - xver: derived.xver ?? 0, - }; - }); - if (additions.length === 0) return prev; - return [...prev, ...additions]; - }); - }; - - const genRealityKeypair = async () => { - setSaving(true); - try { - const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert'); - if (msg?.success) { - const obj = msg.obj as { privateKey: string; publicKey: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey); - } - } finally { - setSaving(false); - } - }; - - const clearRealityKeypair = () => { - form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], ''); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], ''); - }; - - const genMldsa65 = async () => { - setSaving(true); - try { - const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65'); - if (msg?.success) { - const obj = msg.obj as { seed: string; verify: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify); - } - } finally { - setSaving(false); - } - }; - - const clearMldsa65 = () => { - form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], ''); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], ''); - }; - - const randomizeRealityTarget = () => { - const tgt = getRandomRealityTarget() as { target: string; sni: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target); - form.setFieldValue( - ['streamSettings', 'realitySettings', 'serverNames'], - tgt.sni.split(',').map((s) => s.trim()).filter(Boolean), - ); - }; - - const randomizeShortIds = () => { - form.setFieldValue( - ['streamSettings', 'realitySettings', 'shortIds'], - RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean), - ); - }; - - const getNewEchCert = async () => { - const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']); - setSaving(true); - try { - const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni }); - if (msg?.success) { - const obj = msg.obj as { echServerKeys: string; echConfigList: string }; - form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys); - form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList); - } - } finally { - setSaving(false); - } - }; - - const clearEchCert = () => { - form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], ''); - form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], ''); - }; - - const generateRandomPinHash = () => { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - let binary = ''; - for (const b of bytes) binary += String.fromCharCode(b); - const hash = btoa(binary); - const current = (form.getFieldValue( - ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], - ) as string[] | undefined) ?? []; - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], - [...current, hash], - ); - }; - - const setCertFromPanel = async (certName: number) => { - setSaving(true); - try { - const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }); - if (msg?.success) { - const obj = msg.obj as { webCertFile?: string; webKeyFile?: string }; - if (!obj.webCertFile && !obj.webKeyFile) { - messageApi.warning(t('pages.inbounds.setDefaultCertEmpty')); - return; - } - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], - obj.webCertFile ?? '', - ); - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], - obj.webKeyFile ?? '', - ); - } - } finally { - setSaving(false); - } - }; - - const clearCertFiles = (certName: number) => { - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], - '', - ); - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], - '', - ); - }; - - const onSecurityChange = async (next: string) => { - const current = (form.getFieldValue('streamSettings') as Record) ?? {}; - const cleaned: Record = { ...current, security: next }; - delete cleaned.tlsSettings; - delete cleaned.realitySettings; - if (next === 'tls') { - const tls = TlsStreamSettingsSchema.parse({}) as Record; - tls.certificates = [{ - useFile: true, - certificateFile: '', - keyFile: '', - certificate: [], - key: [], - oneTimeLoading: false, - usage: 'encipherment', - buildChain: false, - }]; - cleaned.tlsSettings = tls; - } - if (next === 'reality') { - const reality = RealityStreamSettingsSchema.parse({}) as Record; - const tgt = getRandomRealityTarget() as { target: string; sni: string }; - reality.target = tgt.target; - reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean); - reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean); - cleaned.realitySettings = reality; - } - form.setFieldValue('streamSettings', cleaned); - if (next === 'reality') { - try { - const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert'); - if (msg?.success) { - const obj = msg.obj as { privateKey: string; publicKey: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey); - } - } catch { - // best-effort: leave keypair fields empty if server call fails - } - } - }; - const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form); - const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false; - const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form); - const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form); - const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form); - - const toggleExternalProxy = (on: boolean) => { - if (on) { - const port = (form.getFieldValue('port') as number) ?? 443; - form.setFieldValue(['streamSettings', 'externalProxy'], [{ - forceTls: 'same', - dest: typeof window !== 'undefined' ? window.location.hostname : '', - port, - remark: '', - sni: '', - fingerprint: '', - alpn: [], - }]); - } else { - form.setFieldValue(['streamSettings', 'externalProxy'], []); - } - }; - - const toggleSockopt = (on: boolean) => { - if (on) { - form.setFieldValue( - ['streamSettings', 'sockopt'], - SockoptStreamSettingsSchema.parse({}), - ); - } else { - form.setFieldValue(['streamSettings', 'sockopt'], undefined); - } - }; - const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form); - const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0 - ? Wireguard.generateKeypair(wgSecretKey).publicKey - : ''; - - const regenInboundWg = () => { - const kp = Wireguard.generateKeypair(); - form.setFieldValue(['settings', 'secretKey'], kp.privateKey); - }; - - const regenWgPeerKeypair = (peerName: number) => { - const kp = Wireguard.generateKeypair(); - form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey); - form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey); - }; - - const matchesVlessAuth = ( - block: { id?: string; label?: string } | undefined | null, - authId: string, - ) => { - if (block?.id === authId) return true; - const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, ''); - if (authId === 'mlkem768') return label.includes('mlkem768'); - if (authId === 'x25519') return label.includes('x25519'); - return false; - }; - - const getNewVlessEnc = async (authId: string) => { - if (!authId) return; - setSaving(true); - try { - const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc'); - if (!msg?.success) return; - const obj = msg.obj as { - auths?: { decryption: string; encryption: string; label?: string; id?: string }[]; - }; - const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId)); - if (!block) return; - form.setFieldValue(['settings', 'decryption'], block.decryption); - form.setFieldValue(['settings', 'encryption'], block.encryption); - } finally { - setSaving(false); - } - }; - - const clearVlessEnc = () => { - form.setFieldValue(['settings', 'decryption'], 'none'); - form.setFieldValue(['settings', 'encryption'], 'none'); - }; - - const selectedVlessAuth = (() => { - const enc = typeof vlessEncryption === 'string' ? vlessEncryption : ''; - if (!enc || enc === 'none') return 'None'; - const parts = enc.split('.').filter(Boolean); - const authKey = parts[parts.length - 1] || ''; - if (!authKey) return t('pages.inbounds.vlessAuthCustom'); - return authKey.length > 300 - ? t('pages.inbounds.vlessAuthMlkem768') - : t('pages.inbounds.vlessAuthX25519'); - })(); - - useEffect(() => { - if (!open) return; - const initial = mode === 'edit' && dbInbound - ? rawInboundToFormValues(dbInbound) - : buildAddModeValues(); - form.resetFields(); - form.setFieldsValue(initial); - if ( - mode === 'edit' - && dbInbound - && (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN) - ) { - loadFallbacks(dbInbound.id); - } else { - setFallbacks([]); - } - - }, [open, mode, dbInbound, form]); - - // Why: protocol picker reset cascades through the form — clearing the - // settings DU branch and dropping a nodeId that no longer applies. The - // legacy modal did this imperatively in onProtocolChange; here we hook - // into AntD's onValuesChange and let setFieldValue keep the rest of - // the form state intact. - const onValuesChange = (changed: Partial) => { - if (mode === 'edit') return; - if ('protocol' in changed && typeof changed.protocol === 'string') { - const next = changed.protocol; - const settings = createDefaultInboundSettings(next) ?? undefined; - form.setFieldValue('settings', settings); - if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) { - form.setFieldValue('nodeId', null); - } - // Hysteria uses its dedicated transport — force the network branch - // so the stream tab renders the hysteria sub-form, not the leftover - // tcpSettings from the previous protocol. When leaving hysteria, - // snap back to TCP so the standard network selector has a valid - // starting point. - if (next === Protocols.HYSTERIA) { - const tls = TlsStreamSettingsSchema.parse({}) as Record; - tls.certificates = [{ - useFile: true, - certificateFile: '', - keyFile: '', - certificate: [], - key: [], - oneTimeLoading: false, - usage: 'encipherment', - buildChain: false, - }]; - form.setFieldValue('streamSettings', { - network: 'hysteria', - security: 'tls', - hysteriaSettings: HysteriaStreamSettingsSchema.parse({}), - tlsSettings: tls, - // Hysteria2 needs an obfs wrapper on the FinalMask side; seed - // it with salamander + a random password so the listener boots - // with a usable default. Re-selecting Hysteria from another - // protocol re-runs this and refreshes the password — that's - // intentional, the form was already being reset. - finalmask: { - tcp: [], - udp: [{ - type: 'salamander', - settings: { password: RandomUtil.randomLowerAndNum(16) }, - }], - }, - }); - } else { - const current = form.getFieldValue('streamSettings') as { network?: string } | undefined; - if (current?.network === 'hysteria') { - form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} }); - } - } - } - }; - - const submit = async () => { - try { - await form.validateFields(); - } catch { - return; - } - // Why getFieldsValue(true) instead of the validateFields return value: - // rc-component/form's validateFields filters its output by REGISTERED - // name paths. settings.clients and settings.fallbacks have no Form.Item - // bound to them (clients are managed via the standalone Client modal, - // not inside this inbound modal) — so validateFields would drop them - // and the update wire payload would silently delete every client on - // every save. getFieldsValue(true) returns the entire form store and - // keeps those sub-trees intact. - const values = form.getFieldsValue(true) as InboundFormValues; - const parsed = InboundFormSchema.safeParse(values); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - const path = Array.isArray(issue?.path) && issue.path.length > 0 - ? issue.path.join('.') - : ''; - const baseMsg = issue?.message ?? 'somethingWentWrong'; - const display = path ? `${path}: ${baseMsg}` : baseMsg; - messageApi.error(t(baseMsg, { defaultValue: display })); - console.error('[InboundFormModal] schema validation failed', { - path: issue?.path, - message: issue?.message, - values, - }); - return; - } - setSaving(true); - try { - const payload = formValuesToWirePayload(parsed.data); - const url = mode === 'edit' && dbInbound - ? `/panel/api/inbounds/update/${dbInbound.id}` - : '/panel/api/inbounds/add'; - const msg = await HttpUtil.post(url, payload); - if (msg?.success) { - if (isFallbackHost) { - const obj = msg.obj as { id?: number; Id?: number } | null; - const masterId = mode === 'edit' - ? dbInbound!.id - : (obj?.id ?? obj?.Id ?? 0); - if (masterId) await saveFallbacks(masterId); - } - onSaved(); - onClose(); - } - } finally { - setSaving(false); - } - }; - - const title = mode === 'edit' - ? t('pages.inbounds.modifyInbound') - : t('pages.inbounds.addInbound'); - - const okText = mode === 'edit' - ? t('pages.clients.submitEdit') - : t('create'); - - const basicTab = ( - <> - - - - - - - - - - - - - - - - - {selectableNodes.length > 0 && isNodeEligible && ( - - - - - - - - - - - - - - {t('pages.inbounds.totalFlow')} - - } - > - prev.total !== curr.total} - > - {({ getFieldValue, setFieldValue }) => { - const totalBytes = (getFieldValue('total') as number) ?? 0; - const totalGB = totalBytes - ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100 - : 0; - return ( - { - const bytes = NumberFormatter.toFixed( - (Number(v) || 0) * SizeFormatter.ONE_GB, - 0, - ); - setFieldValue('total', bytes); - }} - /> - ); - }} - - - - - - ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()), - }} - style={{ width: '100%' }} - onChange={(v) => updateFallback(record.rowKey, { childId: v })} - /> - - - - - - SNI - updateFallback(record.rowKey, { name: e.target.value })} - /> - ALPN - updateFallback(record.rowKey, { alpn: e.target.value })} - /> - Path - updateFallback(record.rowKey, { path: e.target.value })} - /> - Dest - updateFallback(record.rowKey, { dest: e.target.value })} - /> - xver - updateFallback(record.rowKey, { xver: Number(v) || 0 })} - /> - - - ))} - - - - - - ); - - const protocolTab = ( - <> - {protocol === Protocols.WIREGUARD && ( - <> - - - - - - - - {fields.map((field, idx) => ( -
- - - {t('pages.inbounds.info.peerNumber', { n: idx + 1 })} - {fields.length > 1 && ( - - {ipFields.map((ipField) => ( - - - - - {ipFields.length > 1 && ( - - )} - - ))} - - )} - - - - -
- ))} - - )} - - - )} - - {protocol === Protocols.TUN && ( - <> - - - - - - - - {(fields, { add, remove }) => ( - - - {fields.map((field, j) => ( - - - - - - - ))} - - )} - - - {(fields, { add, remove }) => ( - - - {fields.map((field, j) => ( - - - - - - - ))} - - )} - - - - - - {(fields, { add, remove }) => ( - - {t('pages.inbounds.info.autoSystemRoutes')} - - } - > - - {fields.map((field, j) => ( - - - - - - - ))} - - )} - - - {t('pages.inbounds.form.autoOutboundsInterface')} - - } - > - - - - )} - - {protocol === Protocols.TUNNEL && ( - <> - - - - - - - - - - - - - - - ))} -
- )} - - )} - - {protocol === Protocols.HTTP && ( - - - - )} - {protocol === Protocols.MIXED && ( - <> - - - - )} - - )} - - )} - - {protocol === Protocols.SHADOWSOCKS && ( - <> - - - - - - - - - {t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })} - - - {network === 'tcp' && (security === 'tls' || security === 'reality') && ( - - - {[900, 500, 900, 256].map((def, i) => ( - - - - ))} - - - )} - - )} - - {isFallbackHost && fallbacksCard} - - ); - - // Switching `network` swaps which per-network key (tcpSettings, - // wsSettings, grpcSettings, ...) appears on the wire. Clear the old - // network's blob and seed the new one with the schema defaults so the - // Form.Items inside it have valid initial values (KCP needs MTU=1350 - // etc., not empty strings). - // Seed each network's settings blob with its Zod schema defaults so - // every Form.Item inside the network sub-form has a defined starting - // value. XHTTP in particular has ~20 fields (sessionPlacement, - // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value - // is the literal "" sentinel meaning "let xray-core pick its - // default". Without seeding "", the Form.Item reads `undefined` and - // the Select shows blank instead of the "Default (path)" option. - const newStreamSlice = (n: string): Record => { - switch (n) { - case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } }); - case 'kcp': return KcpStreamSettingsSchema.parse({}); - case 'ws': return WsStreamSettingsSchema.parse({}); - case 'grpc': return GrpcStreamSettingsSchema.parse({}); - case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({}); - case 'xhttp': return XHttpStreamSettingsSchema.parse({}); - default: return {}; - } - }; - const onNetworkChange = (next: string) => { - const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings']; - const current = (form.getFieldValue('streamSettings') as Record) ?? {}; - const cleaned: Record = { ...current, network: next }; - for (const k of ALL) { - if (k !== `${next}Settings`) delete cleaned[k]; - } - cleaned[`${next}Settings`] = newStreamSlice(next); - // mKCP wants a UDP mask wrapper on the FinalMask side; seed it with - // `mkcp-original` so the inbound boots with a sensible default - // instead of unobfuscated mKCP traffic. The user can still edit or - // clear the mask via the FinalMask section. - if (next === 'kcp') { - const fm = (cleaned.finalmask as Record | undefined) ?? {}; - const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : []; - const hasMkcp = udp.some((m) => { - const entry = m as { type?: string }; - return entry?.type === 'mkcp-original'; - }); - if (!hasMkcp) { - cleaned.finalmask = { - ...fm, - udp: [...udp, { type: 'mkcp-original', settings: {} }], - }; - } - } - form.setFieldValue('streamSettings', cleaned); - }; - - const streamTab = ( - <> - {protocol !== Protocols.HYSTERIA && ( - - - - - - - ({ value: Array.isArray(v) ? v.join(',') : v })} - getValueFromEvent={(e) => { - const raw = (e?.target?.value ?? '') as string; - const parts = raw.split(',').map((s) => s.trim()).filter(Boolean); - return parts.length > 0 ? parts : ['/']; - }} - > - - - - - - - - - - - - - - - - - - - ); - }} - - - )} - - {network === 'ws' && ( - <> - - - - - - - - - - - - - - - - - )} - - {network === 'grpc' && ( - <> - - - - - - - - - - - )} - - {network === 'xhttp' && ( - <> - - - - - - - - - - - )} - {xhttpMode === 'stream-up' && ( - - - - )} - - - - - - - - - - - - - - - - - - - - )} - - - - )} - - - - )} - {xhttpMode === 'packet-up' && ( - <> - - - - )} - - )} - - - - - )} - - {network === 'httpupgrade' && ( - <> - - - - - - - - - - - - - - )} - - {network === 'kcp' && ( - <> - - - - - - - - - - - - - - - - - - - - )} - - { - const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy; - const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy; - return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0); - }} - > - {({ getFieldValue }) => { - const arr = getFieldValue(['streamSettings', 'externalProxy']); - const on = Array.isArray(arr) && arr.length > 0; - return ( - <> - - - - {on && ( - - {(fields, { add, remove }) => ( - <> - - - - - {fields.map((field) => ( -
- - - - - - - - - - - remove(field.name)}> - - - - - prev.streamSettings?.externalProxy?.[field.name]?.forceTls - !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls - } - > - {({ getFieldValue }) => { - const ft = getFieldValue([ - 'streamSettings', 'externalProxy', field.name, 'forceTls', - ]); - if (ft !== 'tls') return null; - return ( - - - - - - ({ - value: a, - label: a, - }))} - /> - - - ); - }} - -
- ))} -
- - )} -
- )} - - ); - }} -
- - { - const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt; - const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt; - return !!a !== !!b; - }} - > - {({ getFieldValue }) => { - const sock = getFieldValue(['streamSettings', 'sockopt']); - const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0; - return ( - <> - - - - {on && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ({ value: c, label: c }))} - /> - - - - - - - - - ({ value: v, label: v }))} - /> - - - {({ getFieldValue, setFieldValue }) => { - const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']); - const hasHe = he != null; - return ( - <> - - { - setFieldValue( - ['streamSettings', 'sockopt', 'happyEyeballs'], - v ? HappyEyeballsSchema.parse({}) : undefined, - ); - }} - /> - - {hasHe && ( - <> - - - - - - - - - - - - - - )} - - ); - }} - - - {(fields, { add, remove }) => ( - <> - - - - {fields.map((field) => ( - - - - - - - - - - - - - - - - ))} - - )} - - - )} - - ); - }} - - - - - ); - - const securityTab = ( - <> - - - - prev.streamSettings?.security !== curr.streamSettings?.security - || prev.streamSettings?.network !== curr.streamSettings?.network - || prev.protocol !== curr.protocol - } - > - {({ getFieldValue }) => { - const sec = getFieldValue(['streamSettings', 'security']) ?? 'none'; - const net = getFieldValue(['streamSettings', 'network']) ?? ''; - const proto = getFieldValue('protocol') ?? ''; - const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } }); - const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } }); - const tlsOnly = proto === Protocols.HYSTERIA; - return ( - onSecurityChange(e.target.value)} - > - {!tlsOnly && {t('none')}} - TLS - {realityOk && Reality} - - ); - }} - - - - - prev.streamSettings?.security !== curr.streamSettings?.security - } - > - {({ getFieldValue }) => { - const sec = getFieldValue(['streamSettings', 'security']); - if (sec !== 'tls') return null; - return ( - <> - - - - - ({ value: v, label: v }))} - /> - - - ({ value: fp, label: fp })), - ]} - /> - - - - - - - - - - - - - - - ) : ( - <> - typeof v === 'string' - ? v.split('\n') - : v} - getValueProps={(v) => ({ - value: Array.isArray(v) ? v.join('\n') : v, - })} - > -