From 3a950e75913357ce2d039e8ff556dcd4bdc70e04 Mon Sep 17 00:00:00 2001 From: rustmailer Date: Thu, 21 May 2026 10:38:04 +0800 Subject: [PATCH] fix: account name don't change when Update Account #248 --- crates/core/src/account/migration.rs | 4 + web/package.json | 14 +- web/pnpm-lock.yaml | 1192 ++++++++++++++++- .../components/__tests__/schema-edge.test.ts | 145 ++ .../components/__tests__/schema.test.ts | 324 +++++ .../accounts/components/action-dialog.tsx | 109 +- .../features/accounts/components/schema.ts | 114 ++ .../features/auth/__tests__/schema.test.ts | 81 ++ web/src/features/auth/schema.ts | 16 + web/src/features/auth/user-auth-form.tsx | 17 +- .../components/__tests__/schema.test.ts | 189 +++ .../oauth2/components/action-dialog.tsx | 118 +- web/src/features/oauth2/components/schema.ts | 56 + .../settings/profile/__tests__/schema.test.ts | 153 +++ .../settings/profile/profile-form.tsx | 37 +- web/src/features/settings/profile/schema.ts | 37 + .../proxy/components/__tests__/schema.test.ts | 173 +++ .../proxy/components/action-dialog.tsx | 75 +- .../settings/proxy/components/schema.ts | 65 + .../roles/components/__tests__/schema.test.ts | 129 ++ .../users/roles/components/action-dialog.tsx | 15 +- .../features/users/roles/components/schema.ts | 11 + .../users/components/__tests__/schema.test.ts | 308 +++++ .../users/users/components/action-dialog.tsx | 73 +- .../features/users/users/components/schema.ts | 106 ++ web/src/test/setup.ts | 1 + web/src/test/test-utils.tsx | 37 + web/src/vite-env.d.ts | 1 + web/vite.config.ts | 9 +- 29 files changed, 3181 insertions(+), 428 deletions(-) create mode 100644 web/src/features/accounts/components/__tests__/schema-edge.test.ts create mode 100644 web/src/features/accounts/components/__tests__/schema.test.ts create mode 100644 web/src/features/accounts/components/schema.ts create mode 100644 web/src/features/auth/__tests__/schema.test.ts create mode 100644 web/src/features/auth/schema.ts create mode 100644 web/src/features/oauth2/components/__tests__/schema.test.ts create mode 100644 web/src/features/oauth2/components/schema.ts create mode 100644 web/src/features/settings/profile/__tests__/schema.test.ts create mode 100644 web/src/features/settings/profile/schema.ts create mode 100644 web/src/features/settings/proxy/components/__tests__/schema.test.ts create mode 100644 web/src/features/settings/proxy/components/schema.ts create mode 100644 web/src/features/users/roles/components/__tests__/schema.test.ts create mode 100644 web/src/features/users/roles/components/schema.ts create mode 100644 web/src/features/users/users/components/__tests__/schema.test.ts create mode 100644 web/src/features/users/users/components/schema.ts create mode 100644 web/src/test/setup.ts create mode 100644 web/src/test/test-utils.tsx diff --git a/crates/core/src/account/migration.rs b/crates/core/src/account/migration.rs index 83569b4..23729a5 100644 --- a/crates/core/src/account/migration.rs +++ b/crates/core/src/account/migration.rs @@ -369,6 +369,10 @@ impl Account { new.folder_limit = Some(folder_limit); } + if let Some(account_name) = request.account_name { + new.account_name = Some(account_name); + } + if let Some(clear_folder_limit) = request.clear_folder_limit { if clear_folder_limit { new.folder_limit = None; diff --git a/web/package.json b/web/package.json index d11d631..d6c77af 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,10 @@ "preview": "vite preview", "format:check": "prettier --check .", "format": "prettier --write .", - "knip": "knip" + "knip": "knip", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -81,6 +84,9 @@ "@tanstack/react-query-devtools": "^5.62.3", "@tanstack/router-devtools": "^1.86.1", "@tanstack/router-plugin": "^1.86.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", @@ -88,18 +94,22 @@ "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react-swc": "^3.7.2", + "@vitest/coverage-v8": "^4.1.7", "autoprefixer": "^10.4.20", "eslint": "^9.16.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.13.0", + "jsdom": "^29.1.1", "knip": "^5.41.1", + "msw": "^2.14.6", "postcss": "^8.4.49", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.9", "tailwindcss": "^3.4.16", "typescript": "~5.7.2", "typescript-eslint": "^8.17.0", - "vite": "^6.0.11" + "vite": "^6.0.11", + "vitest": "^4.1.7" } } \ No newline at end of file diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 851b34f..cf05dd4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -207,6 +207,15 @@ importers: '@tanstack/router-plugin': specifier: ^1.86.0 version: 1.86.0(vite@6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0))(webpack@5.99.9) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@trivago/prettier-plugin-sort-imports': specifier: ^4.3.0 version: 4.3.0(prettier@3.4.2) @@ -228,6 +237,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^3.7.2 version: 3.7.2(vite@6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0)) + '@vitest/coverage-v8': + specifier: ^4.1.7 + version: 4.1.7(vitest@4.1.7) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) @@ -243,9 +255,15 @@ importers: globals: specifier: ^15.13.0 version: 15.13.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 knip: specifier: ^5.41.1 version: 5.41.1(@types/node@22.10.1)(typescript@5.7.2) + msw: + specifier: ^2.14.6 + version: 2.14.6(@types/node@22.10.1)(typescript@5.7.2) postcss: specifier: ^8.4.49 version: 8.4.49 @@ -267,9 +285,15 @@ importers: vite: specifier: ^6.0.11 version: 6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0) + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@22.10.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(msw@2.14.6(@types/node@22.10.1)(typescript@5.7.2))(vite@6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0)) packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -278,6 +302,21 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -336,10 +375,18 @@ packages: resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.25.9': resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.25.9': resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} engines: {node: '>=6.9.0'} @@ -353,6 +400,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.25.9': resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} engines: {node: '>=6.9.0'} @@ -389,6 +441,10 @@ packages: resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@base-ui-components/utils@0.1.2': resolution: {integrity: sha512-aEitDGpMsYO2qnSpYOwZNykn9Rzn2ioyEVk2fyDRH7t+TIHVKpp9CeV7SPTq43M9mMSDxQ+7UeZJVkrj2dCVIQ==} peerDependencies: @@ -399,6 +455,50 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -784,6 +884,15 @@ packages: resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + 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 + '@faker-js/faker@9.3.0': resolution: {integrity: sha512-r0tJ3ZOkMd9xsu3VRfqlFR6cz0V/jFYRswAIpC+m/DIfAUXq7g8N7wTAlhSANySXYGKzGryfDXwtwsY8TxEIDw==} engines: {node: '>=18.0.0', npm: '>=9.0.0'} @@ -831,6 +940,41 @@ packages: resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} engines: {node: '>=18.18'} + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/confirm@6.0.13': + resolution: {integrity: sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.1.10': + resolution: {integrity: sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -865,6 +1009,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + '@mui/core-downloads-tracker@7.3.5': resolution: {integrity: sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==} @@ -979,6 +1127,18 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -2655,6 +2815,9 @@ packages: engines: {node: '>=8.10'} hasBin: true + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.9.2': resolution: {integrity: sha512-nETmsCoY29krTF2PtspEgicb3tqw7Ci5sInTI03EU5zpqYbPjoPH99BVTjj0OsF53jP5MxwnLI5Hm21lUn1d6A==} engines: {node: '>=10'} @@ -2839,6 +3002,35 @@ packages: resolution: {integrity: sha512-jV5mWJrsh3QXHpb/by6udSqwva0qK50uYHpIXvKsLaxnlbjbLfflfPjFyRWXbMtZsnzCjSUqp5pm5/p+Wpaerg==} engines: {node: '>=12'} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@trivago/prettier-plugin-sort-imports@4.3.0': resolution: {integrity: sha512-r3n0onD3BTOVUNPhR4lhVK4/pABGpbA7bW3eumZnYdKaHkf1qEC+Mag6DPbGNuuh0eG8AaYj+YqmVHSiGslaTQ==} peerDependencies: @@ -2848,6 +3040,9 @@ packages: '@vue/compiler-sfc': optional: true + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2860,6 +3055,9 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -2890,6 +3088,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2951,6 +3152,12 @@ packages: '@types/react@18.3.18': resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3027,6 +3234,44 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 + '@vitest/coverage-v8@4.1.7': + resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==} + peerDependencies: + '@vitest/browser': 4.1.7 + vitest: 4.1.7 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -3128,6 +3373,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -3149,6 +3398,20 @@ packages: resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3179,6 +3442,9 @@ packages: resolution: {integrity: sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3220,6 +3486,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3255,6 +3525,14 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -3299,6 +3577,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -3307,6 +3589,13 @@ packages: resolution: {integrity: sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3359,6 +3648,10 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} @@ -3380,6 +3673,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.1.0: resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==} @@ -3414,6 +3710,12 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -3443,12 +3745,19 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.23.1: resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} engines: {node: '>=18'} @@ -3531,6 +3840,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3542,6 +3854,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3562,12 +3878,30 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3625,6 +3959,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -3670,6 +4008,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -3689,9 +4031,19 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -3770,6 +4122,9 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -3778,9 +4133,24 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -3799,6 +4169,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3806,6 +4179,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} engines: {node: '>=4'} @@ -3882,6 +4264,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3890,6 +4276,20 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3938,6 +4338,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4041,6 +4444,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -4058,6 +4465,20 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.14.6: + resolution: {integrity: sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -4097,10 +4518,16 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -4131,6 +4558,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4146,10 +4576,16 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4161,6 +4597,10 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -4274,6 +4714,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.2.0: resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} engines: {node: '>=18'} @@ -4346,6 +4790,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -4461,6 +4908,10 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4473,6 +4924,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4491,6 +4946,9 @@ packages: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -4511,6 +4969,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -4530,6 +4992,9 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4538,6 +5003,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -4564,6 +5032,19 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4583,6 +5064,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4620,6 +5105,13 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@2.5.5: resolution: {integrity: sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA==} @@ -4675,6 +5167,28 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -4683,6 +5197,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4710,6 +5232,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + typescript-eslint@8.17.0: resolution: {integrity: sha512-409VXvFd/f1br1DCbuKNFqQpXICoTB+V51afcwG1pn1a3Cp92MqAUges3YjwEdQ0cMUoCIodjVDAYzyD8h3SYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4736,6 +5262,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4758,6 +5288,9 @@ packages: resolution: {integrity: sha512-5liCNPuJW8dqh3+DM6uNM2EI3MLLpCKp/KY+9pB5M2S2SR2qvvDHhKgBOaTWEbZTAws3CXfB0rKTIolWKL05VQ==} engines: {node: '>=14.0.0'} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.1.1: resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true @@ -4881,10 +5414,55 @@ packages: yaml: optional: true + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watchpack@2.4.4: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} @@ -4892,6 +5470,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -4909,11 +5491,24 @@ packages: webpack-cli: optional: true + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4929,6 +5524,17 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4941,6 +5547,14 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4977,6 +5591,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -4984,6 +5600,26 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + 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 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -5071,8 +5707,12 @@ snapshots: '@babel/helper-string-parser@7.25.9': {} + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.25.9': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.25.9': {} '@babel/helpers@7.26.0': @@ -5084,6 +5724,10 @@ snapshots: dependencies: '@babel/types': 7.26.0 + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -5139,6 +5783,11 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@base-ui-components/utils@0.1.2(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -5150,6 +5799,36 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.4.1': {} '@emotion/babel-plugin@11.13.5': @@ -5423,6 +6102,8 @@ snapshots: dependencies: levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@faker-js/faker@9.3.0': {} '@floating-ui/core@1.6.8': @@ -5461,6 +6142,33 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} + '@inquirer/ansi@2.0.5': {} + + '@inquirer/confirm@6.0.13(@types/node@22.10.1)': + dependencies: + '@inquirer/core': 11.1.10(@types/node@22.10.1) + '@inquirer/type': 4.0.5(@types/node@22.10.1) + optionalDependencies: + '@types/node': 22.10.1 + + '@inquirer/core@11.1.10(@types/node@22.10.1)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@22.10.1) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.10.1 + + '@inquirer/figures@2.0.5': {} + + '@inquirer/type@4.0.5(@types/node@22.10.1)': + optionalDependencies: + '@types/node': 22.10.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5494,8 +6202,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': - optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.25': dependencies: @@ -5506,7 +6213,15 @@ snapshots: dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - optional: true + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 '@mui/core-downloads-tracker@7.3.5': {} @@ -5629,6 +6344,17 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -7299,6 +8025,8 @@ snapshots: ignore: 5.3.2 p-map: 4.0.0 + '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.9.2': optional: true @@ -7465,6 +8193,40 @@ snapshots: '@tanstack/virtual-file-routes@1.81.9': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.4.2)': dependencies: '@babel/generator': 7.17.7 @@ -7477,6 +8239,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.26.2 @@ -7498,6 +8262,11 @@ snapshots: dependencies: '@babel/types': 7.26.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -7526,6 +8295,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -7544,8 +8315,7 @@ snapshots: '@types/estree@1.0.6': {} - '@types/estree@1.0.8': - optional: true + '@types/estree@1.0.8': {} '@types/file-saver@2.0.7': {} @@ -7570,7 +8340,6 @@ snapshots: '@types/node@22.19.2': dependencies: undici-types: 6.21.0 - optional: true '@types/parse-json@4.0.2': {} @@ -7591,6 +8360,12 @@ snapshots: '@types/prop-types': 15.7.14 csstype: 3.1.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 22.19.2 + + '@types/statuses@2.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -7686,6 +8461,62 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitest/coverage-v8@4.1.7(vitest@4.1.7)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.7 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.7(@types/node@22.10.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(msw@2.14.6(@types/node@22.10.1)(typescript@5.7.2))(vite@6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0)) + + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(msw@2.14.6(@types/node@22.10.1)(typescript@5.7.2))(vite@6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.6(@types/node@22.10.1)(typescript@5.7.2) + vite: 6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -7831,6 +8662,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -7848,6 +8681,20 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + asynckit@0.4.0: {} autoprefixer@10.4.20(postcss@8.4.49): @@ -7890,6 +8737,10 @@ snapshots: baseline-browser-mapping@2.9.6: optional: true + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} brace-expansion@1.1.11: @@ -7932,6 +8783,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -7971,6 +8824,14 @@ snapshots: clean-stack@2.2.0: {} + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: optional: true @@ -8011,6 +8872,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + cosmiconfig@7.1.0: dependencies: '@types/parse-json': 4.0.2 @@ -8025,6 +8888,13 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -8067,6 +8937,13 @@ snapshots: d3-timer@3.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + date-fns-jalali@4.1.0-0: {} date-fns@3.6.0: {} @@ -8079,6 +8956,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.1.0: dependencies: character-entities: 2.0.2 @@ -8107,6 +8986,10 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -8140,6 +9023,8 @@ snapshots: tapable: 2.3.0 optional: true + entities@8.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -8147,6 +9032,8 @@ snapshots: es-module-lexer@1.7.0: optional: true + es-module-lexer@2.1.0: {} + esbuild@0.23.1: optionalDependencies: '@esbuild/aix-ppc64': 0.23.1 @@ -8293,6 +9180,10 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@4.0.7: {} @@ -8300,6 +9191,8 @@ snapshots: events@3.3.0: optional: true + expect-type@1.3.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -8318,13 +9211,27 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: optional: true + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.17.1: dependencies: reusify: 1.0.4 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8371,6 +9278,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-nonce@1.0.1: {} get-tsconfig@4.8.1: @@ -8411,6 +9320,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.14.0: {} + handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -8450,10 +9361,23 @@ snapshots: dependencies: '@types/hast': 3.0.4 + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -8517,12 +9441,29 @@ snapshots: is-hexadecimal@2.0.1: {} + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -8542,12 +9483,40 @@ snapshots: jiti@2.4.2: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + 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.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + 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.5.0 + 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 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@2.5.2: {} jsesc@3.0.2: {} @@ -8618,6 +9587,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -8626,6 +9597,22 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.6.3 + markdown-table@3.0.4: {} mdast-util-find-and-replace@3.0.2: @@ -8781,6 +9768,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + merge-stream@2.0.0: optional: true @@ -8988,6 +9977,8 @@ snapshots: dependencies: mime-db: 1.52.0 + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -9002,6 +9993,33 @@ snapshots: ms@2.1.3: {} + msw@2.14.6(@types/node@22.10.1)(typescript@5.7.2): + dependencies: + '@inquirer/confirm': 6.0.13(@types/node@22.10.1) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.0 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.7.2 + transitivePeerDependencies: + - '@types/node' + + mute-stream@3.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -9030,6 +10048,8 @@ snapshots: object-hash@3.0.0: {} + obug@2.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -9039,6 +10059,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + outvariant@1.4.3: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -9076,6 +10098,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -9087,14 +10113,20 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@6.3.0: {} + path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.2: {} + picomatch@4.0.4: {} + pify@2.3.0: {} pirates@4.0.6: {} @@ -9146,6 +10178,12 @@ snapshots: prettier@3.4.2: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.2.0: dependencies: parse-ms: 4.0.0 @@ -9262,6 +10300,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-is@19.2.0: {} @@ -9393,6 +10433,11 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -9427,8 +10472,9 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - require-from-string@2.0.2: - optional: true + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} reselect@5.1.1: {} @@ -9442,6 +10488,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + rettime@0.11.11: {} + reusify@1.0.4: {} rollup@4.26.0: @@ -9484,6 +10532,10 @@ snapshots: '@parcel/watcher': 2.5.1 optional: true + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -9505,12 +10557,16 @@ snapshots: randombytes: 2.1.0 optional: true + set-cookie-parser@3.1.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} smol-toml@1.3.1: {} @@ -9529,6 +10585,14 @@ snapshots: space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + strict-event-emitter@0.5.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -9554,6 +10618,10 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} strip-json-comments@5.0.1: {} @@ -9591,6 +10659,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + + tagged-tag@1.0.0: {} + tailwind-merge@2.5.5: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.16): @@ -9659,12 +10731,37 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + to-fast-properties@2.0.0: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -9688,6 +10785,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + typescript-eslint@8.17.0(eslint@9.16.0(jiti@2.4.2))(typescript@5.7.2): dependencies: '@typescript-eslint/eslint-plugin': 8.17.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0(jiti@2.4.2))(typescript@5.7.2))(eslint@9.16.0(jiti@2.4.2))(typescript@5.7.2) @@ -9706,8 +10807,9 @@ snapshots: undici-types@6.20.0: {} - undici-types@6.21.0: - optional: true + undici-types@6.21.0: {} + + undici@7.25.0: {} unified@11.0.5: dependencies: @@ -9747,6 +10849,8 @@ snapshots: acorn: 8.14.0 webpack-virtual-modules: 0.6.2 + until-async@3.0.2: {} + update-browserslist-db@1.1.1(browserslist@4.24.2): dependencies: browserslist: 4.24.2 @@ -9854,8 +10958,41 @@ snapshots: tsx: 4.19.2 yaml: 2.6.0 + vitest@4.1.7(@types/node@22.10.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(msw@2.14.6(@types/node@22.10.1)(typescript@5.7.2))(vite@6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(msw@2.14.6(@types/node@22.10.1)(typescript@5.7.2))(vite@6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 6.0.11(@types/node@22.10.1)(jiti@2.4.2)(sass@1.89.2)(terser@5.43.1)(tsx@4.19.2)(yaml@2.6.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.10.1 + '@vitest/coverage-v8': 4.1.7(vitest@4.1.7) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 @@ -9867,6 +11004,8 @@ snapshots: defaults: 1.0.4 optional: true + webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: optional: true @@ -9904,10 +11043,25 @@ snapshots: - uglify-js optional: true + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} @@ -9924,12 +11078,30 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@1.10.2: {} yaml@2.6.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} zod-validation-error@3.4.0(zod@3.23.8): diff --git a/web/src/features/accounts/components/__tests__/schema-edge.test.ts b/web/src/features/accounts/components/__tests__/schema-edge.test.ts new file mode 100644 index 0000000..c456864 --- /dev/null +++ b/web/src/features/accounts/components/__tests__/schema-edge.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest' +import { getAccountSchema } from '../schema' + +const t = (key: string) => key + +const baseData = { + email: 'test@example.com', + imap: { + host: 'imap.example.com', + port: 993, + encryption: 'Ssl' as const, + auth: { + auth_type: 'Password' as const, + password: 'mypassword', + }, + }, + enabled: true, + use_dangerous: false, + download_interval_min: 60, + download_batch_size: 30, + auto_download_new_mailboxes: true, +} + +describe('Account Schema - date_since validation', () => { + const schema = getAccountSchema(false, t) + + it('accepts fixed date_since', () => { + const result = schema.safeParse({ + ...baseData, + date_since: { fixed: '2024-01-01' }, + }) + expect(result.success).toBe(true) + }) + + it('accepts relative date_since', () => { + const result = schema.safeParse({ + ...baseData, + date_since: { relative: { unit: 'Months', value: 6 } }, + }) + expect(result.success).toBe(true) + }) + + it('accepts undefined date_since', () => { + const result = schema.safeParse(baseData) + expect(result.success).toBe(true) + }) + + it('rejects relative date_since with value 0', () => { + const result = schema.safeParse({ + ...baseData, + date_since: { relative: { unit: 'Months', value: 0 } }, + }) + expect(result.success).toBe(false) + }) + + it('rejects relative date_since with negative value', () => { + const result = schema.safeParse({ + ...baseData, + date_since: { relative: { unit: 'Months', value: -1 } }, + }) + expect(result.success).toBe(false) + }) + + it('rejects relative date_since with non-integer value', () => { + const result = schema.safeParse({ + ...baseData, + date_since: { relative: { unit: 'Months', value: 1.5 } }, + }) + expect(result.success).toBe(false) + }) + + it('rejects fixed date_since with empty string', () => { + const result = schema.safeParse({ + ...baseData, + date_since: { fixed: '' }, + }) + expect(result.success).toBe(false) + }) +}) + +describe('Account Schema - date_before validation', () => { + const schema = getAccountSchema(false, t) + + it('accepts valid date_before', () => { + const result = schema.safeParse({ + ...baseData, + date_before: { unit: 'Days', value: 30 }, + }) + expect(result.success).toBe(true) + }) + + it('accepts undefined date_before', () => { + const result = schema.safeParse(baseData) + expect(result.success).toBe(true) + }) + + it('rejects date_before with value 0', () => { + const result = schema.safeParse({ + ...baseData, + date_before: { unit: 'Days', value: 0 }, + }) + expect(result.success).toBe(false) + }) +}) + +describe('Account Schema - use_dangerous and enabled flags', () => { + const schema = getAccountSchema(false, t) + + it('accepts use_dangerous: true', () => { + const result = schema.safeParse({ ...baseData, use_dangerous: true }) + expect(result.success).toBe(true) + }) + + it('accepts enabled: false', () => { + const result = schema.safeParse({ ...baseData, enabled: false }) + expect(result.success).toBe(true) + }) + + it('accepts auto_download_new_mailboxes: false', () => { + const result = schema.safeParse({ + ...baseData, + auto_download_new_mailboxes: false, + }) + expect(result.success).toBe(true) + }) +}) + +describe('Account Schema - missing required nested fields', () => { + const schema = getAccountSchema(false, t) + + it('rejects missing imap entirely', () => { + const { imap, ...noImap } = baseData + const result = schema.safeParse(noImap) + expect(result.success).toBe(false) + }) + + it('rejects missing imap.auth', () => { + const { auth, ...noAuth } = baseData.imap + const result = schema.safeParse({ + ...baseData, + imap: noAuth, + }) + expect(result.success).toBe(false) + }) +}) diff --git a/web/src/features/accounts/components/__tests__/schema.test.ts b/web/src/features/accounts/components/__tests__/schema.test.ts new file mode 100644 index 0000000..50a7293 --- /dev/null +++ b/web/src/features/accounts/components/__tests__/schema.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect } from 'vitest' +import { getAccountSchema, getAuthConfigSchema } from '../schema' + +const t = (key: string) => key + +const validAccountData = { + email: 'user@example.com', + imap: { + host: 'imap.example.com', + port: 993, + encryption: 'Ssl' as const, + auth: { + auth_type: 'Password' as const, + password: 'mypassword', + }, + }, + enabled: true, + use_dangerous: false, + download_interval_min: 60, + download_batch_size: 30, + auto_download_new_mailboxes: true, +} + +describe('Account Form Schema', () => { + describe('email field', () => { + const schema = getAccountSchema(false, t) + + it('rejects empty email', () => { + const result = schema.safeParse({ ...validAccountData, email: '' }) + expect(result.success).toBe(false) + }) + + it('rejects invalid email format', () => { + const result = schema.safeParse({ + ...validAccountData, + email: 'not-an-email', + }) + expect(result.success).toBe(false) + }) + + it('rejects email without @', () => { + const result = schema.safeParse({ + ...validAccountData, + email: 'username', + }) + expect(result.success).toBe(false) + }) + + it('accepts valid email', () => { + const result = schema.safeParse(validAccountData) + expect(result.success).toBe(true) + }) + }) + + describe('imap.host field', () => { + it('rejects empty IMAP host', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, host: '' }, + }) + expect(result.success).toBe(false) + }) + + it('accepts valid hostname', () => { + const result = getAccountSchema(false, t).safeParse(validAccountData) + expect(result.success).toBe(true) + }) + + it('accepts IP address as host', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, host: '192.168.1.1' }, + }) + expect(result.success).toBe(true) + }) + }) + + describe('imap.port field', () => { + it('accepts port 993 (standard IMAP SSL)', () => { + const result = getAccountSchema(false, t).safeParse(validAccountData) + expect(result.success).toBe(true) + }) + + it('accepts port 143 (standard IMAP)', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, port: 143 }, + }) + expect(result.success).toBe(true) + }) + + it('accepts port 0 (auto-detect)', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, port: 0 }, + }) + expect(result.success).toBe(true) + }) + + it('rejects negative port', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, port: -1 }, + }) + expect(result.success).toBe(false) + }) + + it('rejects port > 65535', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, port: 99999 }, + }) + expect(result.success).toBe(false) + }) + + it('rejects non-integer port', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, port: 993.5 }, + }) + expect(result.success).toBe(false) + }) + }) + + describe('imap.encryption field', () => { + it('accepts Ssl', () => { + const result = getAccountSchema(false, t).safeParse(validAccountData) + expect(result.success).toBe(true) + }) + + it('accepts StartTls', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, encryption: 'StartTls' }, + }) + expect(result.success).toBe(true) + }) + + it('accepts None', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, encryption: 'None' }, + }) + expect(result.success).toBe(true) + }) + + it('rejects invalid encryption value', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + imap: { ...validAccountData.imap, encryption: 'TLS' }, + }) + expect(result.success).toBe(false) + }) + }) + + describe('download_interval_min field', () => { + it('rejects value less than 10', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + download_interval_min: 5, + }) + expect(result.success).toBe(false) + }) + + it('accepts value of exactly 10', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + download_interval_min: 10, + }) + expect(result.success).toBe(true) + }) + + it('rejects non-integer value', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + download_interval_min: 30.5, + }) + expect(result.success).toBe(false) + }) + }) + + describe('download_batch_size field', () => { + it('rejects value less than 10', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + download_batch_size: 5, + }) + expect(result.success).toBe(false) + }) + + it('rejects value greater than 200', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + download_batch_size: 500, + }) + expect(result.success).toBe(false) + }) + + it('accepts value of exactly 10', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + download_batch_size: 10, + }) + expect(result.success).toBe(true) + }) + + it('accepts value of exactly 200', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + download_batch_size: 200, + }) + expect(result.success).toBe(true) + }) + }) + + describe('folder_limit field', () => { + it('accepts undefined folder_limit', () => { + const result = getAccountSchema(false, t).safeParse(validAccountData) + expect(result.success).toBe(true) + }) + + it('accepts null folder_limit', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + folder_limit: null, + }) + expect(result.success).toBe(true) + }) + + it('rejects folder_limit less than 100', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + folder_limit: 50, + }) + expect(result.success).toBe(false) + }) + + it('accepts folder_limit of exactly 100', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + folder_limit: 100, + }) + expect(result.success).toBe(true) + }) + }) + + describe('account_name and login_name fields', () => { + it('accepts undefined account_name and login_name', () => { + const result = getAccountSchema(false, t).safeParse(validAccountData) + expect(result.success).toBe(true) + }) + + it('accepts provided account_name', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + account_name: 'My Work Email', + }) + expect(result.success).toBe(true) + }) + + it('accepts provided login_name', () => { + const result = getAccountSchema(false, t).safeParse({ + ...validAccountData, + login_name: 'username', + }) + expect(result.success).toBe(true) + }) + }) +}) + +describe('Auth Config Schema (password validation)', () => { + describe('when creating (isEdit = false)', () => { + const schema = getAuthConfigSchema(false, t) + + it('requires password when auth_type is Password', () => { + const result = schema.safeParse({ + auth_type: 'Password', + password: '', + }) + expect(result.success).toBe(false) + }) + + it('requires password when auth_type is Password and password undefined', () => { + const result = schema.safeParse({ + auth_type: 'Password', + }) + expect(result.success).toBe(false) + }) + + it('accepts valid password with Password auth', () => { + const result = schema.safeParse({ + auth_type: 'Password', + password: 'mypassword', + }) + expect(result.success).toBe(true) + }) + + it('does not require password when auth_type is OAuth2', () => { + const result = schema.safeParse({ + auth_type: 'OAuth2', + }) + expect(result.success).toBe(true) + }) + }) + + describe('when editing (isEdit = true)', () => { + const schema = getAuthConfigSchema(true, t) + + it('does not require password even with Password auth', () => { + const result = schema.safeParse({ + auth_type: 'Password', + password: '', + }) + expect(result.success).toBe(true) + }) + + it('accepts with undefined password', () => { + const result = schema.safeParse({ + auth_type: 'Password', + }) + expect(result.success).toBe(true) + }) + }) +}) diff --git a/web/src/features/accounts/components/action-dialog.tsx b/web/src/features/accounts/components/action-dialog.tsx index 6ab046c..cdb6296 100644 --- a/web/src/features/accounts/components/action-dialog.tsx +++ b/web/src/features/accounts/components/action-dialog.tsx @@ -19,7 +19,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import * as React from 'react'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Form } from '@/components/ui/form'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -35,112 +34,9 @@ import { ToastAction } from '@/components/ui/toast'; import { AxiosError } from 'axios'; import { useTranslation } from 'react-i18next'; import { cn } from "@/lib/utils"; +import { getAccountSchema, type AccountFormValues } from './schema'; -const encryptionSchema = z.union([ - z.literal('Ssl'), - z.literal('StartTls'), - z.literal('None'), -]); - -const authTypeSchema = z.union([ - z.literal('Password'), - z.literal('OAuth2'), -]); - -const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) => - z.object({ - auth_type: authTypeSchema, - password: z.string().optional(), - }).refine( - (data) => { - if (data.auth_type === 'Password' && !isEdit) { - return !!data.password?.trim(); - } - return true; - }, - { - message: t('validation.passwordRequired'), - path: ['password'], - } - ); - -const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) => - z.object({ - host: z.string({ required_error: t('validation.imapHostRequired') }).min(1, { message: t('validation.imapHostCannotBeEmpty') }), - port: z.number().int().min(0, { message: t('validation.imapPortMustBePositive') }).max(65535, { message: t('validation.imapPortMustBeLessThan65536') }), - encryption: encryptionSchema, - auth: getAuthConfigSchema(isEdit, t), - use_proxy: z.number().optional(), - }); - -const getRelativeDateSchema = (t: (key: string) => string) => z.object({ - unit: z.enum(["Days", "Months", "Years"], { message: t('accounts.selectUnit') }), - value: z.number({ message: t('accounts.enterValue') }).int().min(1, t('accounts.mustBeAtLeast1')), -}); - -const getDateSelectionSchema = (t: (key: string) => string) => z.union([ - z.object({ fixed: z.string({ message: t('accounts.selectDate') }) }), - z.object({ relative: getRelativeDateSchema(t) }), - z.undefined(), -]); - -export type Account = { - login_name?: string; - account_name?: string; - email: string; - imap: { - host: string; - port: number; - encryption: 'Ssl' | 'StartTls' | 'None'; - auth: { - auth_type: 'Password' | 'OAuth2'; - password?: string; - }; - use_proxy?: number; - }; - enabled: boolean; - use_dangerous: boolean; - date_since?: { - fixed?: string; - relative?: { - unit?: 'Days' | 'Months' | 'Years'; - value?: number; - }; - }; - date_before?: { - unit?: 'Days' | 'Months' | 'Years'; - value?: number; - }; - folder_limit?: number; - download_interval_min: number; - download_batch_size: number; - auto_download_new_mailboxes: boolean; -}; - -const getAccountSchema = (isEdit: boolean, t: (key: string) => string) => - z.object({ - account_name: z.string().optional(), - login_name: z.string().optional(), - email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }), - imap: getImapConfigSchema(isEdit, t), - enabled: z.boolean(), - use_dangerous: z.boolean(), - date_since: getDateSelectionSchema(t).optional(), - date_before: getRelativeDateSchema(t).optional(), - folder_limit: z - .number({ invalid_type_error: t('validation.folderLimitMustBeNumber') }) - .int() - .min(100, { message: t('validation.folderLimitMustBeAtLeast100') }) - .nullable() - .optional(), - download_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }), - download_batch_size: z - .number({ invalid_type_error: t('validation.singleRequestBatchSizeMustBeNumber') }) - .int() - .min(10, { message: t('validation.singleRequestBatchSizeTooSmall') }) - .max(200, { message: t('validation.singleRequestBatchSizeTooLarge') }), - auto_download_new_mailboxes: z.boolean(), - }); +export type Account = AccountFormValues; type Step = { id: `step-${number}`; @@ -205,6 +101,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => { } return { + account_name: currentRow.account_name ?? undefined, login_name: currentRow.login_name ?? undefined, email: currentRow.email, imap, diff --git a/web/src/features/accounts/components/schema.ts b/web/src/features/accounts/components/schema.ts new file mode 100644 index 0000000..700375a --- /dev/null +++ b/web/src/features/accounts/components/schema.ts @@ -0,0 +1,114 @@ +import { z } from 'zod' + +const encryptionSchema = z.union([ + z.literal('Ssl'), + z.literal('StartTls'), + z.literal('None'), +]) + +const authTypeSchema = z.union([ + z.literal('Password'), + z.literal('OAuth2'), +]) + +export const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) => + z + .object({ + auth_type: authTypeSchema, + password: z.string().optional(), + }) + .refine( + (data) => { + if (data.auth_type === 'Password' && !isEdit) { + return !!data.password?.trim() + } + return true + }, + { + message: t('validation.passwordRequired'), + path: ['password'], + } + ) + +export const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) => + z.object({ + host: z + .string({ required_error: t('validation.imapHostRequired') }) + .min(1, { message: t('validation.imapHostCannotBeEmpty') }), + port: z + .number() + .int() + .min(0, { message: t('validation.imapPortMustBePositive') }) + .max(65535, { message: t('validation.imapPortMustBeLessThan65536') }), + encryption: encryptionSchema, + auth: getAuthConfigSchema(isEdit, t), + use_proxy: z.number().optional(), + }) + +const relativeDateSchema = (t: (key: string) => string) => + z.object({ + unit: z.enum(['Days', 'Months', 'Years'], { + message: t('accounts.selectUnit'), + }), + value: z + .number({ message: t('accounts.enterValue') }) + .int() + .min(1, t('accounts.mustBeAtLeast1')), + }) + +const dateSelectionSchema = (t: (key: string) => string) => + z + .object({ + fixed: z + .string({ message: t('accounts.selectDate') }) + .min(1, { message: t('accounts.selectDate') }) + .optional(), + relative: relativeDateSchema(t).optional(), + }) + .optional() + +export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) => + z.object({ + account_name: z.string().optional(), + login_name: z.string().optional(), + email: z + .string({ required_error: t('validation.emailRequired') }) + .email({ message: t('validation.invalidEmail') }), + imap: getImapConfigSchema(isEdit, t), + enabled: z.boolean(), + use_dangerous: z.boolean(), + date_since: dateSelectionSchema(t).optional(), + date_before: relativeDateSchema(t).optional(), + folder_limit: z + .number({ invalid_type_error: t('validation.folderLimitMustBeNumber') }) + .int() + .min(100, { message: t('validation.folderLimitMustBeAtLeast100') }) + .nullable() + .optional(), + download_interval_min: z + .number({ + invalid_type_error: t('validation.incrementalSyncMustBeNumber'), + }) + .int() + .min(10, { + message: t('validation.incrementalSyncMustBeAtLeast10'), + }), + download_batch_size: z + .number({ + invalid_type_error: t( + 'validation.singleRequestBatchSizeMustBeNumber' + ), + }) + .int() + .min(10, { + message: t('validation.singleRequestBatchSizeTooSmall'), + }) + .max(200, { + message: t('validation.singleRequestBatchSizeTooLarge'), + }), + auto_download_new_mailboxes: z.boolean(), + }) + +export type AccountFormValues = z.infer< + ReturnType +> diff --git a/web/src/features/auth/__tests__/schema.test.ts b/web/src/features/auth/__tests__/schema.test.ts new file mode 100644 index 0000000..a139cd4 --- /dev/null +++ b/web/src/features/auth/__tests__/schema.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { getFormSchema } from '../schema' + +// Simple mock t function that returns the key +const t = (key: string, _options?: Record) => key + +describe('Login Form Schema', () => { + const schema = getFormSchema(t) + + describe('username field', () => { + it('rejects empty username', () => { + const result = schema.safeParse({ username: '', password: 'abcd' }) + expect(result.success).toBe(false) + if (!result.success) { + const usernameErrors = result.error.issues.filter( + (i) => i.path[0] === 'username' + ) + expect(usernameErrors.length).toBeGreaterThan(0) + } + }) + + it('accepts valid username with password', () => { + const result = schema.safeParse({ username: 'admin', password: 'pass1234' }) + expect(result.success).toBe(true) + }) + + it('accepts email as username', () => { + const result = schema.safeParse({ + username: 'user@example.com', + password: 'mypassword', + }) + expect(result.success).toBe(true) + }) + }) + + describe('password field', () => { + it('rejects empty password', () => { + const result = schema.safeParse({ username: 'admin', password: '' }) + expect(result.success).toBe(false) + if (!result.success) { + const passwordErrors = result.error.issues.filter( + (i) => i.path[0] === 'password' + ) + expect(passwordErrors.length).toBeGreaterThan(0) + } + }) + + it('rejects password shorter than 4 characters', () => { + const result = schema.safeParse({ username: 'admin', password: 'ab' }) + expect(result.success).toBe(false) + }) + + it('accepts password of exactly 4 characters', () => { + const result = schema.safeParse({ + username: 'admin', + password: 'abcd', + }) + expect(result.success).toBe(true) + }) + + it('accepts long password', () => { + const result = schema.safeParse({ + username: 'admin', + password: 'a'.repeat(256), + }) + expect(result.success).toBe(true) + }) + }) + + describe('missing fields', () => { + it('rejects empty object', () => { + const result = schema.safeParse({}) + expect(result.success).toBe(false) + }) + + it('rejects object with only username', () => { + const result = schema.safeParse({ username: 'admin' }) + expect(result.success).toBe(false) + }) + }) +}) diff --git a/web/src/features/auth/schema.ts b/web/src/features/auth/schema.ts new file mode 100644 index 0000000..60bae80 --- /dev/null +++ b/web/src/features/auth/schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const getFormSchema = ( + t: (key: string, options?: Record) => string +) => + z.object({ + username: z + .string() + .min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }), + password: z + .string() + .min(1, { message: t('validation.pleaseEnterPassword') }) + .min(4, { message: t('validation.passwordMinLength', { min: 4 }) }), + }) + +export type LoginFormValues = z.infer> diff --git a/web/src/features/auth/user-auth-form.tsx b/web/src/features/auth/user-auth-form.tsx index 60f1cb2..4b4bcc7 100644 --- a/web/src/features/auth/user-auth-form.tsx +++ b/web/src/features/auth/user-auth-form.tsx @@ -18,10 +18,10 @@ import { HTMLAttributes, useState } from 'react' -import { z } from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { cn, toSearchParams } from '@/lib/utils' +import { getFormSchema, type LoginFormValues } from './schema' import { Form, FormControl, @@ -47,17 +47,6 @@ import { useTheme } from '@/context/theme-context' type UserAuthFormProps = HTMLAttributes -const getFormSchema = (t: (key: string, options?: Record) => string) => - z.object({ - username: z - .string() - .min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }), - password: z - .string() - .min(1, { message: t('validation.pleaseEnterPassword') }) - .min(4, { message: t('validation.passwordMinLength', { min: 4 }) }), - }); - export function UserAuthForm({ className, ...props }: UserAuthFormProps) { const [isLoading, setIsLoading] = useState(false) const { setTheme } = useTheme(); @@ -68,7 +57,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { const redirect = toSearchParams(search).get('redirect') || '/'; const formSchema = getFormSchema(t) - const form = useForm>({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { username: '', @@ -81,7 +70,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { retry: 0, }); - async function onSubmit(data: z.infer) { + async function onSubmit(data: LoginFormValues) { setIsLoading(true) mutation.mutate(data, { diff --git a/web/src/features/oauth2/components/__tests__/schema.test.ts b/web/src/features/oauth2/components/__tests__/schema.test.ts new file mode 100644 index 0000000..383a4f5 --- /dev/null +++ b/web/src/features/oauth2/components/__tests__/schema.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest' +import { getOAuth2Schema } from '../schema' + +const t = (key: string) => key + +describe('OAuth2 Form Schema', () => { + const schema = getOAuth2Schema(t) + + const validData = { + client_id: 'my-client-id', + auth_url: 'https://accounts.example.com/o/oauth2/auth', + token_url: 'https://oauth2.example.com/token', + redirect_uri: 'https://myapp.example.com/oauth2/callback', + enabled: true, + } + + describe('client_id field', () => { + it('rejects empty client_id', () => { + const result = schema.safeParse({ ...validData, client_id: '' }) + expect(result.success).toBe(false) + }) + + it('accepts valid client_id', () => { + const result = schema.safeParse(validData) + expect(result.success).toBe(true) + }) + }) + + describe('client_secret field', () => { + it('accepts undefined client_secret', () => { + const result = schema.safeParse(validData) + expect(result.success).toBe(true) + }) + + it('accepts provided client_secret', () => { + const result = schema.safeParse({ + ...validData, + client_secret: 'my-secret', + }) + expect(result.success).toBe(true) + }) + }) + + describe('auth_url field', () => { + it('rejects empty auth_url', () => { + const result = schema.safeParse({ ...validData, auth_url: '' }) + expect(result.success).toBe(false) + }) + + it('rejects invalid URL format for auth_url', () => { + const result = schema.safeParse({ + ...validData, + auth_url: 'not-a-url', + }) + expect(result.success).toBe(false) + }) + + it('accepts valid auth_url', () => { + const result = schema.safeParse(validData) + expect(result.success).toBe(true) + }) + }) + + describe('token_url field', () => { + it('rejects empty token_url', () => { + const result = schema.safeParse({ ...validData, token_url: '' }) + expect(result.success).toBe(false) + }) + + it('rejects invalid URL format for token_url', () => { + const result = schema.safeParse({ + ...validData, + token_url: 'not-a-url', + }) + expect(result.success).toBe(false) + }) + }) + + describe('redirect_uri field', () => { + it('rejects empty redirect_uri', () => { + const result = schema.safeParse({ ...validData, redirect_uri: '' }) + expect(result.success).toBe(false) + }) + + it('rejects invalid URL format for redirect_uri', () => { + const result = schema.safeParse({ + ...validData, + redirect_uri: 'not-a-url', + }) + expect(result.success).toBe(false) + }) + }) + + describe('scopes field', () => { + it('accepts empty scopes array', () => { + const result = schema.safeParse({ ...validData, scopes: [] }) + expect(result.success).toBe(true) + }) + + it('accepts valid scopes', () => { + const result = schema.safeParse({ + ...validData, + scopes: [{ value: 'https://mail.google.com/' }], + }) + expect(result.success).toBe(true) + }) + + it('rejects scope with empty value', () => { + const result = schema.safeParse({ + ...validData, + scopes: [{ value: '' }], + }) + expect(result.success).toBe(false) + }) + }) + + describe('extra_params field', () => { + it('accepts empty extra_params array', () => { + const result = schema.safeParse({ ...validData, extra_params: [] }) + expect(result.success).toBe(true) + }) + + it('accepts valid extra_params', () => { + const result = schema.safeParse({ + ...validData, + extra_params: [{ key: 'access_type', value: 'offline' }], + }) + expect(result.success).toBe(true) + }) + + it('rejects param with empty key', () => { + const result = schema.safeParse({ + ...validData, + extra_params: [{ key: '', value: 'offline' }], + }) + expect(result.success).toBe(false) + }) + + it('rejects param with empty value', () => { + const result = schema.safeParse({ + ...validData, + extra_params: [{ key: 'access_type', value: '' }], + }) + expect(result.success).toBe(false) + }) + }) + + describe('enabled field', () => { + it('accepts enabled: true', () => { + const result = schema.safeParse(validData) + expect(result.success).toBe(true) + }) + + it('accepts enabled: false', () => { + const result = schema.safeParse({ ...validData, enabled: false }) + expect(result.success).toBe(true) + }) + }) + + describe('description field', () => { + it('rejects description longer than 255 characters', () => { + const result = schema.safeParse({ + ...validData, + description: 'a'.repeat(256), + }) + expect(result.success).toBe(false) + }) + + it('accepts description of exactly 255 characters', () => { + const result = schema.safeParse({ + ...validData, + description: 'a'.repeat(255), + }) + expect(result.success).toBe(true) + }) + }) + + describe('use_proxy field', () => { + it('accepts undefined use_proxy', () => { + const result = schema.safeParse(validData) + expect(result.success).toBe(true) + }) + + it('accepts numeric use_proxy', () => { + const result = schema.safeParse({ ...validData, use_proxy: 1 }) + expect(result.success).toBe(true) + }) + }) +}) diff --git a/web/src/features/oauth2/components/action-dialog.tsx b/web/src/features/oauth2/components/action-dialog.tsx index b124985..998da6f 100644 --- a/web/src/features/oauth2/components/action-dialog.tsx +++ b/web/src/features/oauth2/components/action-dialog.tsx @@ -17,7 +17,6 @@ // along with this program. If not, see . -import { z } from 'zod' import { useFieldArray, useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { toast } from '@/hooks/use-toast' @@ -53,115 +52,22 @@ import { AxiosError } from 'axios' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import useProxyList from '@/hooks/use-proxy' import { useTranslation } from 'react-i18next' - -const getParamSchema = (t: (key: string) => string) => z.object({ - key: z.string({ required_error: t('oauth2.keyIsRequired') }).min(1, t('oauth2.keyCannotBeEmpty')), - value: z.string({ required_error: t('oauth2.valueIsRequired') }).min(1, t('oauth2.valueCannotBeEmpty')), -}); - -const paramSchema = z.object({ - key: z.string({ required_error: 'Key is required' }).min(1, "Key cannot be empty"), - value: z.string({ required_error: 'Value is required' }).min(1, "Value cannot be empty"), -}); - -const getScopeSchema = (t: (key: string) => string) => z.object({ - value: z.string({ required_error: t('oauth2.valueIsRequired') }).min(1, t('oauth2.valueCannotBeEmpty')), -}); - -const scopeSchema = z.object({ - value: z.string({ required_error: 'Value is required' }).min(1, "Value cannot be empty"), -}); - -const extraparamSchema = z.record(z.string()).optional(); -const authorizescopeSchema = z.array(z.string()).optional(); - +import { getOAuth2Schema, type OAuth2FormValues } from './schema' function convertToExtraParamsSchema( - record: z.infer -): z.infer[] { - if (!record) { - return []; - } - return Object.entries(record).map(([key, value]) => ({ - key, - value, - })); + record: Record | undefined +): { key: string; value: string }[] { + if (!record) return [] + return Object.entries(record).map(([key, value]) => ({ key, value })) } - -function convertToScopeSchema(authorizeScopes: z.infer): z.infer[] { - if (!authorizeScopes || authorizeScopes.length === 0) { - return []; - } - - return authorizeScopes.map((scope) => ({ - value: scope, - })); +function convertToScopeSchema( + scopes: string[] | undefined +): { value: string }[] { + if (!scopes || scopes.length === 0) return [] + return scopes.map((scope) => ({ value: scope })) } -const getOAuth2Schema = (t: (key: string) => string) => z.object({ - description: z.string().max(255, { message: t('oauth2.descriptionMustNotExceed255Characters') }).optional(), - client_id: z.string({ - required_error: t('oauth2.clientIdIsRequired'), - }).min(1, { message: t('oauth2.clientIdCannotBeEmpty') }), - client_secret: z.string().optional(), - auth_url: z.string({ - required_error: t('oauth2.authorizationUrlIsRequired'), - }) - .min(1, { message: t('oauth2.authorizationUrlCannotBeEmpty') }) - .url({ message: t('oauth2.invalidAuthorizationUrlFormat') }), - - token_url: z.string({ - required_error: t('oauth2.tokenUrlIsRequired'), - }) - .min(1, { message: t('oauth2.tokenUrlCannotBeEmpty') }) - .url({ message: t('oauth2.invalidTokenUrlFormat') }), - - redirect_uri: z.string({ - required_error: t('oauth2.redirectUriIsRequired'), - }) - .min(1, { message: t('oauth2.redirectUriCannotBeEmpty') }) - .url({ message: t('oauth2.invalidRedirectUriFormat') }), - - scopes: z.array(getScopeSchema(t)).optional(), - extra_params: z.array(getParamSchema(t)).optional(), - enabled: z.boolean(), - use_proxy: z.number().optional(), -}); - -const oauth2Schema = z.object({ - description: z.string().max(255, { message: "Description must not exceed 255 characters." }).optional(), - client_id: z.string({ - required_error: "Client ID is required", - }).min(1, { message: "Client ID cannot be empty" }), - client_secret: z.string().optional(), - auth_url: z.string({ - required_error: "Authorization URL is required", - }) - .min(1, { message: "Authorization URL cannot be empty" }) - .url({ message: "Invalid Authorization URL format" }), - - token_url: z.string({ - required_error: "Token URL is required", - }) - .min(1, { message: "Token URL cannot be empty" }) - .url({ message: "Invalid Token URL format" }), - - redirect_uri: z.string({ - required_error: "Redirect URI is required", - }) - .min(1, { message: "Redirect URI cannot be empty" }) - .url({ message: "Invalid Redirect URI format" }), - - scopes: z.array(scopeSchema).optional(), - extra_params: z.array(paramSchema).optional(), - enabled: z.boolean(), - use_proxy: z.number().optional(), -}); - -export type OAuth2Form = z.infer; - - interface Props { currentRow?: OAuth2Entity open: boolean @@ -185,7 +91,7 @@ const defaultValues = { export function ActionDialog({ currentRow, open, onOpenChange }: Props) { const { t } = useTranslation() const isEdit = !!currentRow - const form = useForm({ + const form = useForm({ resolver: zodResolver(getOAuth2Schema(t)), defaultValues: isEdit ? { @@ -255,7 +161,7 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) { console.error(error); } - const onSubmit = (values: OAuth2Form) => { + const onSubmit = (values: OAuth2FormValues) => { if (!isEdit) { if (!values.client_secret) { form.setError('client_secret', { diff --git a/web/src/features/oauth2/components/schema.ts b/web/src/features/oauth2/components/schema.ts new file mode 100644 index 0000000..47512cb --- /dev/null +++ b/web/src/features/oauth2/components/schema.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' + +const paramEntry = (t: (key: string) => string) => + z.object({ + key: z + .string({ required_error: t('oauth2.keyIsRequired') }) + .min(1, t('oauth2.keyCannotBeEmpty')), + value: z + .string({ required_error: t('oauth2.valueIsRequired') }) + .min(1, t('oauth2.valueCannotBeEmpty')), + }) + +const scopeEntry = (t: (key: string) => string) => + z.object({ + value: z + .string({ required_error: t('oauth2.valueIsRequired') }) + .min(1, t('oauth2.valueCannotBeEmpty')), + }) + +export const getOAuth2Schema = (t: (key: string) => string) => + z.object({ + description: z + .string() + .max(255, { message: t('oauth2.descriptionMustNotExceed255Characters') }) + .optional(), + client_id: z + .string({ + required_error: t('oauth2.clientIdIsRequired'), + }) + .min(1, { message: t('oauth2.clientIdCannotBeEmpty') }), + client_secret: z.string().optional(), + auth_url: z + .string({ + required_error: t('oauth2.authorizationUrlIsRequired'), + }) + .min(1, { message: t('oauth2.authorizationUrlCannotBeEmpty') }) + .url({ message: t('oauth2.invalidAuthorizationUrlFormat') }), + token_url: z + .string({ + required_error: t('oauth2.tokenUrlIsRequired'), + }) + .min(1, { message: t('oauth2.tokenUrlCannotBeEmpty') }) + .url({ message: t('oauth2.invalidTokenUrlFormat') }), + redirect_uri: z + .string({ + required_error: t('oauth2.redirectUriIsRequired'), + }) + .min(1, { message: t('oauth2.redirectUriCannotBeEmpty') }) + .url({ message: t('oauth2.invalidRedirectUriFormat') }), + scopes: z.array(scopeEntry(t)).optional(), + extra_params: z.array(paramEntry(t)).optional(), + enabled: z.boolean(), + use_proxy: z.number().optional(), + }) + +export type OAuth2FormValues = z.infer> diff --git a/web/src/features/settings/profile/__tests__/schema.test.ts b/web/src/features/settings/profile/__tests__/schema.test.ts new file mode 100644 index 0000000..fb4c79f --- /dev/null +++ b/web/src/features/settings/profile/__tests__/schema.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest' +import { profileSchema } from '../schema' + +const t = (key: string) => key + +describe('Profile Form Schema', () => { + const schema = profileSchema(t) + + describe('username field', () => { + it('rejects empty username', () => { + const result = schema.safeParse({ + username: '', + email: 'user@example.com', + password: '', + }) + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.issues.filter( + (i) => i.path[0] === 'username' + ) + expect(errors.length).toBeGreaterThan(0) + } + }) + + it('rejects username shorter than 3 characters', () => { + const result = schema.safeParse({ + username: 'ab', + email: 'user@example.com', + password: '', + }) + expect(result.success).toBe(false) + }) + + it('accepts username of exactly 3 characters', () => { + const result = schema.safeParse({ + username: 'abc', + email: 'user@example.com', + password: '', + }) + expect(result.success).toBe(true) + }) + + it('rejects username longer than 32 characters', () => { + const result = schema.safeParse({ + username: 'a'.repeat(33), + email: 'user@example.com', + password: '', + }) + expect(result.success).toBe(false) + }) + + it('accepts username of exactly 32 characters', () => { + const result = schema.safeParse({ + username: 'a'.repeat(32), + email: 'user@example.com', + password: '', + }) + expect(result.success).toBe(true) + }) + }) + + describe('email field', () => { + it('rejects empty email', () => { + const result = schema.safeParse({ + username: 'validuser', + email: '', + password: '', + }) + expect(result.success).toBe(false) + }) + + it('rejects invalid email format', () => { + const result = schema.safeParse({ + username: 'validuser', + email: 'not-an-email', + password: '', + }) + expect(result.success).toBe(false) + }) + + it('rejects email without domain', () => { + const result = schema.safeParse({ + username: 'validuser', + email: 'user@', + password: '', + }) + expect(result.success).toBe(false) + }) + + it('accepts valid email', () => { + const result = schema.safeParse({ + username: 'validuser', + email: 'user@example.com', + password: '', + }) + expect(result.success).toBe(true) + }) + }) + + describe('password field', () => { + it('accepts empty password (keep current)', () => { + const result = schema.safeParse({ + username: 'validuser', + email: 'user@example.com', + password: '', + }) + expect(result.success).toBe(true) + if (result.success) { + // Empty password should be transformed to undefined + expect(result.data.password).toBeUndefined() + } + }) + + it('rejects password shorter than 8 characters when provided', () => { + const result = schema.safeParse({ + username: 'validuser', + email: 'user@example.com', + password: 'short', + }) + expect(result.success).toBe(false) + }) + + it('accepts password of exactly 8 characters', () => { + const result = schema.safeParse({ + username: 'validuser', + email: 'user@example.com', + password: '12345678', + }) + expect(result.success).toBe(true) + }) + + it('rejects password longer than 256 characters', () => { + const result = schema.safeParse({ + username: 'validuser', + email: 'user@example.com', + password: 'a'.repeat(257), + }) + expect(result.success).toBe(false) + }) + + it('transforms non-empty password to the string value', () => { + const result = schema.safeParse({ + username: 'validuser', + email: 'user@example.com', + password: 'myNewPassword123', + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.password).toBe('myNewPassword123') + } + }) + }) +}) diff --git a/web/src/features/settings/profile/profile-form.tsx b/web/src/features/settings/profile/profile-form.tsx index be60fa0..a969bdd 100644 --- a/web/src/features/settings/profile/profile-form.tsx +++ b/web/src/features/settings/profile/profile-form.tsx @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { z } from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useMutation, useQueryClient } from '@tanstack/react-query' @@ -42,41 +41,7 @@ import { Badge } from '@/components/ui/badge' import { FileWithPreview } from '@/hooks/use-file-upload' import AvatarUpload from './avatar-upload' import { PermissionsDialog } from '../access/permissions-dialog' - -const profileSchema = (t: (key: string) => string) => z.object({ - username: z - .string({ - required_error: t('settings.profile.validation.username.required'), - }) - .min(3, { - message: t('settings.profile.validation.username.min'), - }) - .max(32, { - message: t('settings.profile.validation.username.max'), - }), - - email: z - .string({ - required_error: t('settings.profile.validation.email.required'), - }) - .email({ - message: t('settings.profile.validation.email.invalid'), - }), - - password: z - .string() - .min(8, { - message: t('settings.profile.validation.password.min'), - }) - .max(256, { - message: t('settings.profile.validation.password.max'), - }) - .or(z.literal('')) - .optional() - .transform((v) => (v ? v : undefined)), -}) - -export type ProfileFormValues = z.infer> +import { profileSchema, type ProfileFormValues } from './schema' function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { diff --git a/web/src/features/settings/profile/schema.ts b/web/src/features/settings/profile/schema.ts new file mode 100644 index 0000000..7220156 --- /dev/null +++ b/web/src/features/settings/profile/schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +export const profileSchema = (t: (key: string) => string) => + z.object({ + username: z + .string({ + required_error: t('settings.profile.validation.username.required'), + }) + .min(3, { + message: t('settings.profile.validation.username.min'), + }) + .max(32, { + message: t('settings.profile.validation.username.max'), + }), + + email: z + .string({ + required_error: t('settings.profile.validation.email.required'), + }) + .email({ + message: t('settings.profile.validation.email.invalid'), + }), + + password: z + .string() + .min(8, { + message: t('settings.profile.validation.password.min'), + }) + .max(256, { + message: t('settings.profile.validation.password.max'), + }) + .or(z.literal('')) + .optional() + .transform((v) => (v ? v : undefined)), + }) + +export type ProfileFormValues = z.infer> diff --git a/web/src/features/settings/proxy/components/__tests__/schema.test.ts b/web/src/features/settings/proxy/components/__tests__/schema.test.ts new file mode 100644 index 0000000..266a187 --- /dev/null +++ b/web/src/features/settings/proxy/components/__tests__/schema.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest' +import { proxyFormSchema } from '../schema' + +describe('Proxy Form Schema', () => { + describe('url field - basic validation', () => { + it('rejects empty URL', () => { + const result = proxyFormSchema.safeParse({ url: '' }) + expect(result.success).toBe(false) + }) + + it('accepts valid socks5 URL', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://127.0.0.1:1080', + }) + expect(result.success).toBe(true) + }) + + it('accepts valid http URL', () => { + const result = proxyFormSchema.safeParse({ + url: 'http://proxy.example.com:8080', + }) + expect(result.success).toBe(true) + }) + }) + + describe('url field - protocol validation', () => { + it('rejects https protocol', () => { + const result = proxyFormSchema.safeParse({ + url: 'https://proxy.example.com:443', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message?.includes('http:// or socks5://') + ) + ).toBe(true) + } + }) + + it('rejects ftp protocol', () => { + const result = proxyFormSchema.safeParse({ + url: 'ftp://files.example.com', + }) + expect(result.success).toBe(false) + }) + + it('rejects URL without protocol', () => { + const result = proxyFormSchema.safeParse({ + url: '127.0.0.1:1080', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message?.includes('Invalid URL format') + ) + ).toBe(true) + } + }) + }) + + describe('url field - port validation', () => { + it('rejects port 0', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://127.0.0.1:0', + }) + expect(result.success).toBe(false) + }) + + it('rejects port > 65535', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://127.0.0.1:99999', + }) + expect(result.success).toBe(false) + }) + + it('accepts port 65535', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://127.0.0.1:65535', + }) + expect(result.success).toBe(true) + }) + + it('accepts port 1', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://127.0.0.1:1', + }) + expect(result.success).toBe(true) + }) + + it('defaults to port 1080 when no port specified', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://127.0.0.1', + }) + expect(result.success).toBe(true) + }) + }) + + describe('url field - hostname validation', () => { + it('accepts IP address hostname', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://192.168.1.1:1080', + }) + expect(result.success).toBe(true) + }) + + it('accepts domain hostname', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://proxy.internal:1080', + }) + expect(result.success).toBe(true) + }) + + it('rejects hostname with invalid characters', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://proxy_host:1080', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message?.includes('Hostname contains invalid characters') + ) + ).toBe(true) + } + }) + }) + + describe('url field - auth validation', () => { + it('rejects username without password', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://user@127.0.0.1:1080', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message?.includes('Password cannot be empty') + ) + ).toBe(true) + } + }) + + it('rejects short password when username provided', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://user:short@127.0.0.1:1080', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message?.includes('Password must be at least 8') + ) + ).toBe(true) + } + }) + + it('accepts valid auth credentials', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://user:password123@127.0.0.1:1080', + }) + expect(result.success).toBe(true) + }) + + it('accepts URL without auth (no credentials)', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://127.0.0.1:1080', + }) + expect(result.success).toBe(true) + }) + }) +}) diff --git a/web/src/features/settings/proxy/components/action-dialog.tsx b/web/src/features/settings/proxy/components/action-dialog.tsx index 4ad984e..37c3f1a 100644 --- a/web/src/features/settings/proxy/components/action-dialog.tsx +++ b/web/src/features/settings/proxy/components/action-dialog.tsx @@ -17,7 +17,6 @@ // along with this program. If not, see . -import { z } from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { toast } from '@/hooks/use-toast' @@ -47,75 +46,7 @@ import { Loader2 } from 'lucide-react' import { add_proxy, update_proxy } from '@/api/system/api' import { useTranslation } from 'react-i18next' import { Proxy } from '@/api/system/api' - -const proxyFormSchema = z.object({ - url: z.string() - .min(1, "Proxy address cannot be empty") - .superRefine((value, ctx) => { - - if (value.length === 0) { - return; - } - - let url: URL; - try { - url = new URL(value); - } catch (e) { - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Invalid URL format", - path: [], - }); - return; - } - - - if (url.protocol !== 'socks5:' && url.protocol !== 'http:') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "URL must start with http:// or socks5://", - path: [], - }); - } - - - if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Hostname contains invalid characters", - path: [], - }); - } - - - const port = parseInt(url.port || '1080'); - if (port <= 0 || port > 65535) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Port must be between 1-65535", - path: [], - }); - } - - - if (url.username && !url.password) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Password cannot be empty when username is provided", - path: [], - }); - } else if (url.password && url.password.length < 8) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Password must be at least 8 characters", - path: [], - }); - } - }) -}); - -export type ProxyForm = z.infer; +import { proxyFormSchema, type ProxyFormValues } from './schema' interface Props { @@ -140,7 +71,7 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) { const { t } = useTranslation() const isEdit = !!currentRow const queryClient = useQueryClient(); - const form = useForm({ + const form = useForm({ resolver: zodResolver(proxyFormSchema), defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) @@ -186,7 +117,7 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) { } - const onSubmit = (values: ProxyForm) => { + const onSubmit = (values: ProxyFormValues) => { const url = values.url; if (isEdit) { updateMutation.mutate(url); diff --git a/web/src/features/settings/proxy/components/schema.ts b/web/src/features/settings/proxy/components/schema.ts new file mode 100644 index 0000000..0b8ef9f --- /dev/null +++ b/web/src/features/settings/proxy/components/schema.ts @@ -0,0 +1,65 @@ +import { z } from 'zod' + +export const proxyFormSchema = z.object({ + url: z + .string() + .min(1, 'Proxy address cannot be empty') + .superRefine((value, ctx) => { + if (value.length === 0) { + return + } + + let url: URL + try { + url = new URL(value) + } catch (_e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid URL format', + path: [], + }) + return + } + + if (url.protocol !== 'socks5:' && url.protocol !== 'http:') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'URL must start with http:// or socks5://', + path: [], + }) + } + + if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Hostname contains invalid characters', + path: [], + }) + } + + const port = parseInt(url.port || '1080') + if (port <= 0 || port > 65535) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Port must be between 1-65535', + path: [], + }) + } + + if (url.username && !url.password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Password cannot be empty when username is provided', + path: [], + }) + } else if (url.password && url.password.length < 8) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Password must be at least 8 characters', + path: [], + }) + } + }), +}) + +export type ProxyFormValues = z.infer diff --git a/web/src/features/users/roles/components/__tests__/schema.test.ts b/web/src/features/users/roles/components/__tests__/schema.test.ts new file mode 100644 index 0000000..9ab9fd6 --- /dev/null +++ b/web/src/features/users/roles/components/__tests__/schema.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest' +import { getRoleFormSchema } from '../schema' + +const t = (key: string) => key + +describe('Role Form Schema', () => { + const schema = getRoleFormSchema(t) + + describe('name field', () => { + it('rejects empty name', () => { + const result = schema.safeParse({ + name: '', + role_type: 'Account', + permissions: ['data:read'], + }) + expect(result.success).toBe(false) + }) + + it('accepts valid name', () => { + const result = schema.safeParse({ + name: 'Viewer', + role_type: 'Account', + permissions: ['data:read'], + }) + expect(result.success).toBe(true) + }) + }) + + describe('role_type field', () => { + it('accepts Global role type', () => { + const result = schema.safeParse({ + name: 'Admin', + role_type: 'Global', + permissions: ['system:access'], + }) + expect(result.success).toBe(true) + }) + + it('accepts Account role type', () => { + const result = schema.safeParse({ + name: 'Viewer', + role_type: 'Account', + permissions: ['data:read'], + }) + expect(result.success).toBe(true) + }) + + it('rejects invalid role type', () => { + const result = schema.safeParse({ + name: 'Test', + role_type: 'Invalid', + permissions: ['data:read'], + }) + expect(result.success).toBe(false) + }) + }) + + describe('permissions field', () => { + it('rejects empty permissions array', () => { + const result = schema.safeParse({ + name: 'Viewer', + role_type: 'Account', + permissions: [], + }) + expect(result.success).toBe(false) + }) + + it('accepts single permission', () => { + const result = schema.safeParse({ + name: 'Viewer', + role_type: 'Account', + permissions: ['data:read'], + }) + expect(result.success).toBe(true) + }) + + it('accepts multiple permissions', () => { + const result = schema.safeParse({ + name: 'Manager', + role_type: 'Account', + permissions: ['data:read', 'data:manage', 'account:manage'], + }) + expect(result.success).toBe(true) + }) + + it('accepts all available permissions', () => { + const result = schema.safeParse({ + name: 'Super Admin', + role_type: 'Global', + permissions: [ + 'system:access', + 'system:root', + 'user:manage', + 'user:view', + 'token:manage', + 'account:create', + 'account:manage:all', + 'data:read:all', + 'data:manage:all', + 'data:raw:download:all', + 'data:delete:all', + 'data:export:batch:all', + ], + }) + expect(result.success).toBe(true) + }) + }) + + describe('description field', () => { + it('accepts undefined description', () => { + const result = schema.safeParse({ + name: 'Viewer', + role_type: 'Account', + permissions: ['data:read'], + }) + expect(result.success).toBe(true) + }) + + it('accepts description string', () => { + const result = schema.safeParse({ + name: 'Viewer', + role_type: 'Account', + permissions: ['data:read'], + description: 'Read-only access to data', + }) + expect(result.success).toBe(true) + }) + }) +}) diff --git a/web/src/features/users/roles/components/action-dialog.tsx b/web/src/features/users/roles/components/action-dialog.tsx index f9ad706..983f024 100644 --- a/web/src/features/users/roles/components/action-dialog.tsx +++ b/web/src/features/users/roles/components/action-dialog.tsx @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { z } from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { toast } from '@/hooks/use-toast' @@ -49,6 +48,7 @@ import { } from '@/components/ui/radio-group' import { cn } from '@/lib/utils' import { useTranslation } from 'react-i18next' +import { getRoleFormSchema, type RoleFormValues } from './schema' interface Props { currentRow?: UserRole @@ -97,16 +97,9 @@ export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) { const queryClient = useQueryClient() const { t } = useTranslation() - const roleFormSchema = z.object({ - name: z.string().min(1, t('roles.validation.name_required')), - role_type: z.enum(['Global', 'Account']), - permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')), - description: z.string().optional(), - }) + const roleFormSchema = getRoleFormSchema(t) - type RoleForm = z.infer - - const form = useForm({ + const form = useForm({ resolver: zodResolver(roleFormSchema), defaultValues: { name: isEdit ? currentRow.name : '', @@ -117,7 +110,7 @@ export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) { }) const mutation = useMutation({ - mutationFn: (values: RoleForm) => + mutationFn: (values: RoleFormValues) => isEdit ? update_role(currentRow!.id, values) : create_role(values), onSuccess: () => { toast({ title: t(isEdit ? 'roles.actions.success_update' : 'roles.actions.success_create') }) diff --git a/web/src/features/users/roles/components/schema.ts b/web/src/features/users/roles/components/schema.ts new file mode 100644 index 0000000..e9331d4 --- /dev/null +++ b/web/src/features/users/roles/components/schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const getRoleFormSchema = (t: (key: string) => string) => + z.object({ + name: z.string().min(1, t('roles.validation.name_required')), + role_type: z.enum(['Global', 'Account']), + permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')), + description: z.string().optional(), + }) + +export type RoleFormValues = z.infer> diff --git a/web/src/features/users/users/components/__tests__/schema.test.ts b/web/src/features/users/users/components/__tests__/schema.test.ts new file mode 100644 index 0000000..f0e050b --- /dev/null +++ b/web/src/features/users/users/components/__tests__/schema.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from 'vitest' +import { getCreateUserSchema, getUpdateUserSchema } from '../schema' + +const t = (key: string) => key + +const validBaseUser = { + username: 'johndoe', + email: 'john@example.com', + global_roles: [1], +} + +const validCreateUser = { + ...validBaseUser, + password: 'securePassword123', +} + +const invalidCases = [ + { desc: 'empty username', data: { ...validCreateUser, username: '' } }, + { + desc: 'username shorter than 3', + data: { ...validCreateUser, username: 'ab' }, + }, + { + desc: 'username longer than 32', + data: { ...validCreateUser, username: 'a'.repeat(33) }, + }, + { desc: 'empty email', data: { ...validCreateUser, email: '' } }, + { + desc: 'invalid email format', + data: { ...validCreateUser, email: 'not-an-email' }, + }, + { + desc: 'empty global_roles', + data: { ...validCreateUser, global_roles: [] }, + }, + { + desc: 'empty password on create', + data: { ...validBaseUser, password: '' }, + }, + { + desc: 'short password on create', + data: { ...validBaseUser, password: 'short' }, + }, + { + desc: 'password longer than 256 on create', + data: { ...validBaseUser, password: 'a'.repeat(257) }, + }, +] + +describe('Create User Schema', () => { + const schema = getCreateUserSchema(t) + + it('accepts valid user data', () => { + const result = schema.safeParse(validCreateUser) + expect(result.success).toBe(true) + }) + + it.each(invalidCases)('rejects $desc', ({ data }) => { + const result = schema.safeParse(data) + expect(result.success).toBe(false) + }) + + it('accepts username of exactly 3 characters', () => { + const result = schema.safeParse({ + ...validCreateUser, + username: 'abc', + }) + expect(result.success).toBe(true) + }) + + it('accepts username of exactly 32 characters', () => { + const result = schema.safeParse({ + ...validCreateUser, + username: 'a'.repeat(32), + }) + expect(result.success).toBe(true) + }) + + it('accepts password of exactly 8 characters', () => { + const result = schema.safeParse({ + ...validBaseUser, + password: '12345678', + }) + expect(result.success).toBe(true) + }) + + it('accepts password of exactly 256 characters', () => { + const result = schema.safeParse({ + ...validBaseUser, + password: 'a'.repeat(256), + }) + expect(result.success).toBe(true) + }) +}) + +describe('Update User Schema', () => { + const schema = getUpdateUserSchema(t) + + it('accepts empty password (keep current)', () => { + const result = schema.safeParse({ + ...validBaseUser, + password: '', + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.password).toBeUndefined() + } + }) + + it('accepts undefined password', () => { + const result = schema.safeParse(validBaseUser) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.password).toBeUndefined() + } + }) + + it('rejects short password when provided', () => { + const result = schema.safeParse({ + ...validBaseUser, + password: 'short', + }) + expect(result.success).toBe(false) + }) + + it('accepts valid password when provided', () => { + const result = schema.safeParse({ + ...validBaseUser, + password: 'newPassword123', + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.password).toBe('newPassword123') + } + }) +}) + +describe('User Schema - ACL', () => { + const schema = getCreateUserSchema(t) + + describe('ip_whitelist validation', () => { + it('accepts valid IPv4 addresses', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { ip_whitelist: '192.168.1.1\n10.0.0.1' }, + }) + expect(result.success).toBe(true) + }) + + it('accepts valid IPv6 address', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { ip_whitelist: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' }, + }) + expect(result.success).toBe(true) + }) + + it('rejects invalid IP format', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { ip_whitelist: 'not-an-ip' }, + }) + expect(result.success).toBe(false) + }) + + it('rejects invalid IP with too many octets', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { ip_whitelist: '192.168.1.1.1' }, + }) + expect(result.success).toBe(false) + }) + + it('rejects IP with octet > 255', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { ip_whitelist: '300.1.1.1' }, + }) + expect(result.success).toBe(false) + }) + + it('accepts empty ACL (no security policies)', () => { + const result = schema.safeParse(validCreateUser) + expect(result.success).toBe(true) + }) + }) + + describe('rate_limit validation', () => { + it('accepts valid rate_limit', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { + rate_limit: { quota: 100, interval: 60 }, + }, + }) + expect(result.success).toBe(true) + }) + + it('transforms ACL with only rate_limit', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { + rate_limit: { quota: 100, interval: 60 }, + }, + }) + expect(result.success).toBe(true) + if (result.success && result.data.acl) { + expect(result.data.acl.rate_limit).toBeDefined() + expect(result.data.acl.rate_limit!.quota).toBe(100) + expect(result.data.acl.ip_whitelist).toBeUndefined() + } + }) + + it('returns undefined for ACL with only empty ip_whitelist', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { ip_whitelist: '' }, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.acl).toBeUndefined() + } + }) + + it('returns undefined for ACL with no data', () => { + const result = schema.safeParse({ + ...validCreateUser, + acl: { ip_whitelist: '\n\n' }, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.acl).toBeUndefined() + } + }) + }) +}) + +describe('User Schema - account_access_entries', () => { + const schema = getCreateUserSchema(t) + + it('accepts empty account_access_entries (defaults to [])', () => { + const result = schema.safeParse(validCreateUser) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.account_access_entries).toEqual([]) + } + }) + + it('accepts valid account access entries', () => { + const result = schema.safeParse({ + ...validCreateUser, + account_access_entries: [ + { accountId: 1, roleId: 2 }, + { accountId: 3, roleId: 4 }, + ], + }) + expect(result.success).toBe(true) + }) + + it('rejects entry with accountId 0', () => { + const result = schema.safeParse({ + ...validCreateUser, + account_access_entries: [{ accountId: 0, roleId: 1 }], + }) + expect(result.success).toBe(false) + }) + + it('rejects entry with roleId 0', () => { + const result = schema.safeParse({ + ...validCreateUser, + account_access_entries: [{ accountId: 1, roleId: 0 }], + }) + expect(result.success).toBe(false) + }) +}) + +describe('User Schema - description', () => { + const schema = getCreateUserSchema(t) + + it('accepts undefined description', () => { + const result = schema.safeParse(validCreateUser) + expect(result.success).toBe(true) + }) + + it('accepts empty string description', () => { + const result = schema.safeParse({ + ...validCreateUser, + description: '', + }) + expect(result.success).toBe(true) + }) + + it('accepts valid description', () => { + const result = schema.safeParse({ + ...validCreateUser, + description: 'A test user account', + }) + expect(result.success).toBe(true) + }) + + it('rejects description longer than 256 characters', () => { + const result = schema.safeParse({ + ...validCreateUser, + description: 'a'.repeat(257), + }) + expect(result.success).toBe(false) + }) +}) diff --git a/web/src/features/users/users/components/action-dialog.tsx b/web/src/features/users/users/components/action-dialog.tsx index 69e2025..f6dad0b 100644 --- a/web/src/features/users/users/components/action-dialog.tsx +++ b/web/src/features/users/users/components/action-dialog.tsx @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { z } from 'zod' import { useState, useMemo } from 'react' import { useFieldArray, useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' @@ -56,75 +55,9 @@ import { useRoles } from '@/hooks/use-roles' import { PasswordInput } from '@/components/password-input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useTranslation } from 'react-i18next' +import { getCreateUserSchema, getUpdateUserSchema, type UserFormValues } from './schema' -const isValidIP = (ip: string) => { - const ipv4 = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/ - const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/ - return ipv4.test(ip) || ipv6.test(ip) -} - -const accountAccessEntry = (t: any) => z.object({ - accountId: z.number().min(1, t('users.actions.schema.account_required')), - roleId: z.number().min(1, t('users.actions.schema.role_required')) -}); - -const baseUserSchema = (t: any) => ({ - username: z.string() - .min(1, t('users.actions.schema.username_required')) - .min(3, t('users.actions.schema.username_min')) - .max(32, t('users.actions.schema.username_max')), - email: z.string() - .min(1, t('users.actions.schema.email_required')) - .email(t('users.actions.schema.email_invalid')), - global_roles: z.array(z.number()).min(1, t('users.actions.schema.global_role_required')), - account_access_entries: z.array(accountAccessEntry(t)).optional().default([]), - description: z.string().max(256, t('users.actions.schema.description_max')).optional().or(z.literal('')), - acl: z.object({ - ip_whitelist: z.string().optional(), - rate_limit: z.object({ - quota: z.number().positive().optional(), - interval: z.number().positive().optional(), - }).optional() - }).optional().transform((data) => { - if (!data) return undefined; - const ips = data.ip_whitelist?.split('\n').map(v => v.trim()).filter(Boolean) || []; - const finalRateLimit = (data.rate_limit?.quota && data.rate_limit?.interval) - ? data.rate_limit - : undefined; - if (ips.length === 0 && !finalRateLimit) return undefined; - return { - ip_whitelist: ips.length > 0 ? ips.join('\n') : undefined, - rate_limit: finalRateLimit - }; - }) - .refine((data) => { - if (!data?.ip_whitelist) return true; - return data.ip_whitelist.split('\n').every(isValidIP); - }, { - message: t('users.actions.schema.ip_invalid'), - path: ["ip_whitelist"] - }) -}); - -const createUserSchema = (t: any) => z.object({ - ...baseUserSchema(t), - password: z.string() - .min(1, t('users.actions.schema.password_required')) - .min(8, t('users.actions.schema.password_min')) - .max(256, t('users.actions.schema.password_max')), -}); - -const updateUserSchema = (t: any) => z.object({ - ...baseUserSchema(t), - password: z.string() - .min(8, t('users.actions.schema.password_min')) - .max(256, t('users.actions.schema.password_max')) - .or(z.literal('')) - .optional() - .transform(v => v || undefined), -}); - -export type UserForm = z.infer> | z.infer> +export type UserForm = UserFormValues interface Props { currentRow?: User @@ -142,7 +75,7 @@ export function UserActionDialog({ currentRow, open, onOpenChange }: Props) { const { minimalList: allAccounts } = useMinimalAccountList() const form = useForm({ - resolver: zodResolver(isEdit ? updateUserSchema(t) : createUserSchema(t)), + resolver: zodResolver(isEdit ? getUpdateUserSchema(t) : getCreateUserSchema(t)), defaultValues: useMemo(() => { if (isEdit && currentRow) { const accessEntries = currentRow.account_access_map diff --git a/web/src/features/users/users/components/schema.ts b/web/src/features/users/users/components/schema.ts new file mode 100644 index 0000000..5d31263 --- /dev/null +++ b/web/src/features/users/users/components/schema.ts @@ -0,0 +1,106 @@ +import { z } from 'zod' + +export const accountAccessEntry = (t: (key: string) => string) => + z.object({ + accountId: z.number().min(1, t('users.actions.schema.account_required')), + roleId: z.number().min(1, t('users.actions.schema.role_required')), + }) + +const isValidIPv4 = (ip: string): boolean => { + const parts = ip.split('.') + if (parts.length !== 4) return false + return parts.every((part) => { + const num = Number(part) + return part === String(num) && num >= 0 && num <= 255 + }) +} + +const isValidIP = (ip: string) => { + const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/ + return isValidIPv4(ip) || ipv6.test(ip) +} + +export const getBaseUserSchema = (t: (key: string) => string) => + z.object({ + username: z + .string() + .min(1, t('users.actions.schema.username_required')) + .min(3, t('users.actions.schema.username_min')) + .max(32, t('users.actions.schema.username_max')), + email: z + .string() + .min(1, t('users.actions.schema.email_required')) + .email(t('users.actions.schema.email_invalid')), + global_roles: z + .array(z.number()) + .min(1, t('users.actions.schema.global_role_required')), + account_access_entries: z + .array(accountAccessEntry(t)) + .optional() + .default([]), + description: z + .string() + .max(256, t('users.actions.schema.description_max')) + .optional() + .or(z.literal('')), + acl: z + .object({ + ip_whitelist: z.string().optional(), + rate_limit: z + .object({ + quota: z.number().positive().optional(), + interval: z.number().positive().optional(), + }) + .optional(), + }) + .optional() + .transform((data) => { + if (!data) return undefined + const ips = + data.ip_whitelist + ?.split('\n') + .map((v) => v.trim()) + .filter(Boolean) || [] + const finalRateLimit = + data.rate_limit?.quota && data.rate_limit?.interval + ? data.rate_limit + : undefined + if (ips.length === 0 && !finalRateLimit) return undefined + return { + ip_whitelist: ips.length > 0 ? ips.join('\n') : undefined, + rate_limit: finalRateLimit, + } + }) + .refine( + (data) => { + if (!data?.ip_whitelist) return true + return data.ip_whitelist.split('\n').every(isValidIP) + }, + { + message: t('users.actions.schema.ip_invalid'), + path: ['ip_whitelist'], + } + ), + }) + +export const getCreateUserSchema = (t: (key: string) => string) => + getBaseUserSchema(t).extend({ + password: z + .string() + .min(1, t('users.actions.schema.password_required')) + .min(8, t('users.actions.schema.password_min')) + .max(256, t('users.actions.schema.password_max')), + }) + +export const getUpdateUserSchema = (t: (key: string) => string) => + getBaseUserSchema(t).extend({ + password: z + .string() + .min(8, t('users.actions.schema.password_min')) + .max(256, t('users.actions.schema.password_max')) + .or(z.literal('')) + .optional() + .transform((v) => v || undefined), + }) + +export type UserFormValues = z.infer> diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/web/src/test/test-utils.tsx b/web/src/test/test-utils.tsx new file mode 100644 index 0000000..cc5e4ee --- /dev/null +++ b/web/src/test/test-utils.tsx @@ -0,0 +1,37 @@ +import { type ReactElement } from 'react' +import { render, type RenderOptions } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { I18nextProvider } from 'react-i18next' +import i18n from '@/i18n' + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) +} + +interface WrapperProps { + children: React.ReactNode +} + +function AllProviders({ children }: WrapperProps) { + const queryClient = createTestQueryClient() + return ( + + + {children} + + + ) +} + +function customRender(ui: ReactElement, options?: Omit) { + return render(ui, { wrapper: AllProviders, ...options }) +} + +export * from '@testing-library/react' +export { customRender as render } +export { createTestQueryClient } diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 11f02fe..d3a8309 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -1 +1,2 @@ /// +/// diff --git a/web/vite.config.ts b/web/vite.config.ts index 762353b..412c883 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,5 +1,6 @@ +/// import path from 'path' -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react-swc' import { TanStackRouterVite } from '@tanstack/router-plugin/vite' @@ -16,4 +17,10 @@ export default defineConfig({ '@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs', }, }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + css: false, + }, })