diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index d1fe8a61..ee5fc2c3 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -44,6 +44,10 @@ jobs: working-directory: web run: pnpm lint + - name: Run unit tests + working-directory: web + run: pnpm test + build: name: Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index abd9d94f..29b7afb2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ dist # Git worktrees .worktrees/ + +# Local pnpm store (project-scoped, created when --config.store-dir is set +# without an existing store; contains a symlink back to the workspace). +.pnpm-store/ + +# Frontend test coverage output (Vitest + @vitest/coverage-v8). +web/coverage/ diff --git a/web/package.json b/web/package.json index 7b5031f5..a4013c6d 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,10 @@ "release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir", "lint": "tsc --noEmit --skipLibCheck && biome check src", "lint:fix": "biome check --write src", - "format": "biome format --write src" + "format": "biome format --write src", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@connectrpc/connect": "^2.1.1", @@ -74,6 +77,8 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@bufbuild/protobuf": "^2.11.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/katex": "^0.16.8", @@ -89,11 +94,13 @@ "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.7.0", "baseline-browser-mapping": "^2.10.8", + "jsdom": "^29.0.2", "long": "^5.3.2", "terser": "^5.46.1", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.4" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 805e5a6c..057b825f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -186,6 +186,12 @@ importers: '@bufbuild/protobuf': specifier: ^2.11.0 version: 2.11.0 + '@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.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/d3': specifier: ^7.4.3 version: 7.4.3 @@ -231,6 +237,9 @@ importers: baseline-browser-mapping: specifier: ^2.10.8 version: 2.10.8 + jsdom: + specifier: ^29.0.2 + version: 29.0.2 long: specifier: ^5.3.2 version: 5.3.2 @@ -246,15 +255,36 @@ importers: vite: specifier: ^7.2.4 version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + vitest: + specifier: ^4.1.4 + version: 4.1.4(@types/node@24.10.1)(jsdom@29.0.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)) packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} '@antfu/utils@9.3.0': resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} + '@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.0.10': + resolution: {integrity: sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==} + 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.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -406,6 +436,10 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bufbuild/protobuf@2.11.0': resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} @@ -435,6 +469,42 @@ packages: peerDependencies: '@bufbuild/protobuf': ^2.7.0 + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + 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.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + 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.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + 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'} + '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -645,6 +715,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + 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 + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1252,6 +1331,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -1363,6 +1445,32 @@ packages: peerDependencies: react: ^18 || ^19 + '@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 + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1375,6 +1483,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1471,6 +1582,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/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1551,6 +1665,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.4': + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + + '@vitest/mocker@4.1.4': + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} + 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.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + + '@vitest/runner@4.1.4': + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + + '@vitest/snapshot@4.1.4': + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + + '@vitest/spy@4.1.4': + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} + + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} @@ -1559,10 +1702,29 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} 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'} + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -1575,6 +1737,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + browserslist@4.28.0: resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1593,6 +1758,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1670,6 +1839,13 @@ packages: resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} engines: {node: '>=8.0.0'} + 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==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1829,6 +2005,10 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -1841,6 +2021,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -1861,6 +2044,12 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.3.3: resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} @@ -1881,6 +2070,9 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1901,6 +2093,13 @@ 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==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -2013,6 +2212,10 @@ packages: 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-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -2044,6 +2247,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2080,6 +2287,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -2090,6 +2300,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2225,6 +2444,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2233,6 +2456,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + 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==} @@ -2298,6 +2525,9 @@ packages: mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mermaid@11.13.0: resolution: {integrity: sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==} @@ -2393,6 +2623,10 @@ packages: engines: {node: '>=16'} hasBin: true + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -2413,6 +2647,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + package-manager-detector@1.5.0: resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} @@ -2430,6 +2667,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -2466,12 +2706,20 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + 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} + property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -2506,6 +2754,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-leaflet-cluster@2.1.0: resolution: {integrity: sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==} peerDependencies: @@ -2594,6 +2845,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} @@ -2621,6 +2876,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -2653,6 +2912,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -2671,6 +2934,9 @@ packages: resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} engines: {node: '>=6.9'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2696,6 +2962,9 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -2705,9 +2974,16 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2724,6 +3000,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -2746,6 +3025,9 @@ packages: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} engines: {node: '>=10'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -2754,9 +3036,28 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 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.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + 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==} @@ -2787,6 +3088,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + 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==} @@ -2890,6 +3195,47 @@ packages: yaml: optional: true + vitest@4.1.4: + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} + 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.4 + '@vitest/browser-preview': 4.1.4 + '@vitest/browser-webdriverio': 4.1.4 + '@vitest/coverage-istanbul': 4.1.4 + '@vitest/coverage-v8': 4.1.4 + '@vitest/ui': 4.1.4 + 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'} @@ -2914,9 +3260,37 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + 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} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + 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==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2929,6 +3303,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.5.0 @@ -2936,6 +3312,26 @@ snapshots: '@antfu/utils@9.3.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@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.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) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.0.10': + 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.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3089,6 +3485,10 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bufbuild/protobuf@2.11.0': {} '@chevrotain/cst-dts-gen@11.1.2': @@ -3117,6 +3517,30 @@ snapshots: dependencies: '@bufbuild/protobuf': 2.11.0 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@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.0(@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.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) + '@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.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.27.1 @@ -3278,6 +3702,8 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -3848,6 +4274,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3931,6 +4359,38 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 18.3.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.6 + '@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.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -3952,6 +4412,11 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -4073,6 +4538,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -4154,14 +4621,67 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.4': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))': + dependencies: + '@vitest/spy': 4.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + + '@vitest/pretty-format@4.1.4': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.4': + dependencies: + '@vitest/utils': 4.1.4 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + '@vitest/utils': 4.1.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.4': {} + + '@vitest/utils@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@xobotyi/scrollbar-width@1.9.5': {} acorn@8.15.0: {} + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + aria-hidden@1.2.6: 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: {} + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.28.4 @@ -4172,6 +4692,10 @@ snapshots: baseline-browser-mapping@2.10.8: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + browserslist@4.28.0: dependencies: baseline-browser-mapping: 2.10.8 @@ -4188,6 +4712,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -4263,6 +4789,13 @@ snapshots: mdn-data: 2.0.14 source-map: 0.6.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): @@ -4449,12 +4982,21 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dayjs@1.11.20: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -4473,6 +5015,10 @@ snapshots: dependencies: dequal: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dompurify@3.3.3: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -4494,6 +5040,8 @@ snapshots: dependencies: stackframe: 1.3.4 + es-module-lexer@2.0.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -4531,6 +5079,12 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + exsolve@1.0.8: {} extend@3.0.2: {} @@ -4690,6 +5244,12 @@ snapshots: dependencies: react-is: 16.13.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -4717,6 +5277,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} inline-style-prefixer@7.0.1: @@ -4746,12 +5308,40 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + jiti@2.6.1: {} js-cookie@2.2.1: {} js-tokens@4.0.0: {} + jsdom@29.0.2: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.0.10 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.5 + parse5: 8.0.0 + 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@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -4851,6 +5441,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.3.5: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -4859,6 +5451,8 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5039,6 +5633,8 @@ snapshots: mdn-data@2.0.14: {} + mdn-data@2.27.1: {} + mermaid@11.13.0: dependencies: '@braintree/sanitize-url': 7.1.1 @@ -5268,6 +5864,8 @@ snapshots: mime@4.1.0: {} + min-indent@1.0.1: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -5294,6 +5892,8 @@ snapshots: node-releases@2.0.27: {} + obug@2.1.1: {} + package-manager-detector@1.5.0: {} parent-module@1.0.1: @@ -5321,6 +5921,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-data-parser@0.1.0: {} path-parse@1.0.7: {} @@ -5358,10 +5962,18 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + property-information@6.5.0: {} property-information@7.1.0: {} + punycode@2.3.1: {} + quansync@0.2.11: {} react-dom@18.3.1(react@18.3.1): @@ -5389,6 +6001,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-leaflet-cluster@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: leaflet: 1.9.4 @@ -5493,6 +6107,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.4 @@ -5563,6 +6182,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -5618,6 +6239,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -5630,6 +6255,8 @@ snapshots: set-harmonic-interval@1.0.1: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5649,6 +6276,8 @@ snapshots: dependencies: stackframe: 1.3.4 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-gps@3.1.2: @@ -5662,11 +6291,17 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + std-env@4.1.0: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -5681,6 +6316,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@3.5.0: {} tailwindcss@4.2.1: {} @@ -5698,6 +6335,8 @@ snapshots: throttle-debounce@3.0.1: {} + tinybench@2.9.0: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -5705,8 +6344,24 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + toggle-selection@1.0.6: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -5725,6 +6380,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.25.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -5821,6 +6478,34 @@ snapshots: lightningcss: 1.31.1 terser: 5.46.1 + vitest@4.1.4(@types/node@24.10.1)(jsdom@29.0.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + jsdom: 29.0.2 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -5840,8 +6525,33 @@ snapshots: vscode-uri@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml@1.10.2: {} diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index ddac3895..c5cbbf28 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -8,6 +8,7 @@ import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import useNavigateTo from "@/hooks/useNavigateTo"; import { userKeys } from "@/hooks/useUserQueries"; import { handleError } from "@/lib/error"; +import { ROUTES } from "@/router/routes"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -74,7 +75,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use } if (isInMemoDetailPage) { - navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived"); + navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED); } memoUpdatedCallback(); }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]); @@ -109,7 +110,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) }); } if (isInMemoDetailPage) { - navigateTo("/"); + navigateTo(ROUTES.HOME); } memoUpdatedCallback(); }, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]); diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx index 361a1ec4..72cb3e2d 100644 --- a/web/src/components/MemoContent/Tag.tsx +++ b/web/src/components/MemoContent/Tag.tsx @@ -42,7 +42,7 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa // If the tag is clicked in a memo detail page, we should navigate to the memo list page. if (location.pathname.startsWith("/m")) { - const pathname = parentPage || Routes.ROOT; + const pathname = parentPage || Routes.ENTRY; const searchParams = new URLSearchParams(); searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }])); diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx index 584eb5e2..211a3982 100644 --- a/web/src/components/Navigation.tsx +++ b/web/src/components/Navigation.tsx @@ -30,7 +30,7 @@ const Navigation = (props: Props) => { const homeNavLink: NavLinkItem = { id: "header-memos", - path: Routes.ROOT, + path: Routes.HOME, title: t("common.memos"), icon: , }; @@ -77,7 +77,7 @@ const Navigation = (props: Props) => { return (
- + diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 7b144fef..47bab664 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -1,14 +1,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { ArrowUpIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { matchPath } from "react-router-dom"; import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext"; import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/connect"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { useInfiniteMemos } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; -import { Routes } from "@/router"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -26,6 +24,8 @@ interface Props { pageSize?: number; showCreator?: boolean; enabled?: boolean; + /** When true, render the inline MemoEditor above the list (e.g. on the Home page). */ + showMemoEditor?: boolean; } function useAutoFetchWhenNotScrollable({ @@ -83,8 +83,7 @@ const PagedMemoList = (props: Props) => { const t = useTranslate(); const queryClient = useQueryClient(); - // Show memo editor only on the root route - const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname)); + const showMemoEditor = props.showMemoEditor ?? false; const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos( { diff --git a/web/src/components/PasswordSignInForm.tsx b/web/src/components/PasswordSignInForm.tsx index 862aa6a4..d9c01985 100644 --- a/web/src/components/PasswordSignInForm.tsx +++ b/web/src/components/PasswordSignInForm.tsx @@ -64,7 +64,7 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) { setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined); } await initialize(); - navigateTo(redirectPath || ROUTES.ROOT, { replace: true }); + navigateTo(redirectPath || ROUTES.HOME, { replace: true }); } catch (error: unknown) { handleError(error, toast.error, { fallbackMessage: "Failed to sign in.", diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index 63b366a1..b6292f7d 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -24,7 +24,7 @@ const MainLayout = () => { // Determine context based on current route const context: MemoExplorerContext = useMemo(() => { - if (location.pathname === Routes.ROOT) return "home"; + if (location.pathname === Routes.HOME) return "home"; if (location.pathname === Routes.EXPLORE) return "explore"; if (matchPath(ARCHIVED_ROUTE, location.pathname)) return "archived"; if (matchPath(PROFILE_ROUTE, location.pathname)) return "profile"; diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index a0ab5f4c..ebee9a56 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -1,15 +1,11 @@ -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { Outlet, useLocation, useSearchParams } from "react-router-dom"; import usePrevious from "react-use/lib/usePrevious"; import Navigation from "@/components/Navigation"; import { useInstance } from "@/contexts/InstanceContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; -import useCurrentUser from "@/hooks/useCurrentUser"; import useMediaQuery from "@/hooks/useMediaQuery"; -import useNavigateTo from "@/hooks/useNavigateTo"; import { cn } from "@/lib/utils"; -import { ROUTES } from "@/router/routes"; -import { redirectOnAuthFailure } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; const MEMOS_DEPLOY_URL = "https://usememos.com/docs/deploy"; @@ -34,25 +30,13 @@ const RootLayout = () => { const location = useLocation(); const [searchParams] = useSearchParams(); const sm = useMediaQuery("sm"); - const currentUser = useCurrentUser(); - const navigateTo = useNavigateTo(); const { profile } = useInstance(); const { removeFilter } = useMemoFilterContext(); - const pathname = useMemo(() => location.pathname, [location.pathname]); + const { pathname } = location; const prevPathname = usePrevious(pathname); useEffect(() => { - if (!currentUser) { - if (pathname === ROUTES.ROOT) { - navigateTo(ROUTES.EXPLORE); - } else { - redirectOnAuthFailure(); - } - } - }, [currentUser, pathname, navigateTo]); - - useEffect(() => { - // When the route changes and there is no filter in the search params, remove all filters + // When the route changes and there is no filter in the search params, remove all filters. if (prevPathname !== pathname && !searchParams.has("filter")) { removeFilter(() => true); } diff --git a/web/src/pages/AuthCallback.tsx b/web/src/pages/AuthCallback.tsx index eb46fb09..5483d1c1 100644 --- a/web/src/pages/AuthCallback.tsx +++ b/web/src/pages/AuthCallback.tsx @@ -7,6 +7,8 @@ import { useAuth } from "@/contexts/AuthContext"; import { absolutifyLink } from "@/helpers/utils"; import useNavigateTo from "@/hooks/useNavigateTo"; import { handleError } from "@/lib/error"; +import { ROUTES } from "@/router/routes"; +import { getSafeRedirectPath } from "@/utils/auth-redirect"; import { validateOAuthState } from "@/utils/oauth"; interface State { @@ -97,8 +99,10 @@ const AuthCallback = () => { errorMessage: "", }); await initialize(); - // Redirect to return URL if specified, otherwise home - navigateTo(returnUrl || "/"); + // Defense-in-depth: even though `returnUrl` was sanitized before being + // stored (see storeOAuthState in SignIn), re-validate on the way out so + // a corrupted state entry can never be used for an open redirect. + navigateTo(getSafeRedirectPath(returnUrl) ?? ROUTES.HOME); } catch (error: unknown) { handleError(error, () => {}, { fallbackMessage: "Failed to authenticate.", diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 25454771..43aa8d33 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -29,6 +29,7 @@ const Home = () => { orderBy={orderBy} filter={memoFilter} enabled={isInitialized} + showMemoEditor />
); diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx index de7e9f28..8e33bf67 100644 --- a/web/src/pages/SignIn.tsx +++ b/web/src/pages/SignIn.tsx @@ -8,7 +8,6 @@ import { Separator } from "@/components/ui/separator"; import { identityProviderServiceClient } from "@/connect"; import { useInstance } from "@/contexts/InstanceContext"; import { absolutifyLink } from "@/helpers/utils"; -import useCurrentUser from "@/hooks/useCurrentUser"; import { handleError } from "@/lib/error"; import { ROUTES } from "@/router/routes"; import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; @@ -18,20 +17,12 @@ import { storeOAuthState } from "@/utils/oauth"; const SignIn = () => { const t = useTranslate(); - const currentUser = useCurrentUser(); const [identityProviderList, setIdentityProviderList] = useState([]); const { generalSetting: instanceGeneralSetting } = useInstance(); const [searchParams] = useSearchParams(); const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`; - // Redirect to root page if already signed in. - useEffect(() => { - if (currentUser?.name) { - window.location.href = redirectTarget || ROUTES.ROOT; - } - }, [currentUser, redirectTarget]); - // Prepare identity provider list. useEffect(() => { const fetchIdentityProviderList = async () => { diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index 9fd6b771..9c7196f0 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -77,7 +77,7 @@ const SignUp = () => { await initAuth(); // Refetch instance profile to update the initialized status await initInstance(); - navigateTo(redirectTarget || ROUTES.ROOT, { replace: true }); + navigateTo(redirectTarget || ROUTES.HOME, { replace: true }); } catch (error: unknown) { handleError(error, toast.error, { fallbackMessage: "Sign up failed", diff --git a/web/src/router/guards.tsx b/web/src/router/guards.tsx new file mode 100644 index 00000000..71466170 --- /dev/null +++ b/web/src/router/guards.tsx @@ -0,0 +1,63 @@ +import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom"; +import useCurrentUser from "@/hooks/useCurrentUser"; +import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath } from "@/utils/auth-redirect"; +import { ROUTES } from "./routes"; + +/** + * Entry-route component mounted at `/`. Performs authentication-aware redirection + * to the correct landing page before any business UI renders, preserving the + * original query string and hash so bookmarks like `/?filter=foo` keep working. + */ +export const LandingRoute = () => { + const currentUser = useCurrentUser(); + const location = useLocation(); + const target = currentUser ? ROUTES.HOME : ROUTES.EXPLORE; + + return ( + + ); +}; + +/** + * Guard for routes that require an authenticated user. Unauthenticated visitors + * are redirected to `/auth` with the original location preserved as the `redirect` + * query parameter, so they return to the intended page after signing in. + */ +export const RequireAuthRoute = () => { + const currentUser = useCurrentUser(); + const location = useLocation(); + + if (!currentUser) { + const redirect = `${location.pathname}${location.search}${location.hash}`; + return ; + } + + return ; +}; + +/** + * Guard for guest-only routes (sign-in and sign-up). Already-authenticated users + * are redirected to the requested `redirect` target (when safe) or to `/home`. + * + * The OAuth callback route (`/auth/callback`) intentionally opts out of this guard: + * an authenticated session in another tab must not prevent the callback from + * consuming its one-time OAuth state and completing the in-flight sign-in. + */ +export const RequireGuestRoute = () => { + const currentUser = useCurrentUser(); + const [searchParams] = useSearchParams(); + + if (currentUser) { + const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); + return ; + } + + return ; +}; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 7375e1e6..774af03b 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -1,10 +1,12 @@ import { lazy } from "react"; -import { createBrowserRouter } from "react-router-dom"; +import { createBrowserRouter, type RouteObject } from "react-router-dom"; import App from "@/App"; import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary"; import MainLayout from "@/layouts/MainLayout"; import RootLayout from "@/layouts/RootLayout"; import Home from "@/pages/Home"; +import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "./guards"; +import { ROUTES } from "./routes"; // Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment). function lazyWithReload(factory: () => Promise<{ default: T }>) { @@ -37,13 +39,16 @@ const SignIn = lazyWithReload(() => import("@/pages/SignIn")); const SignUp = lazyWithReload(() => import("@/pages/SignUp")); const UserProfile = lazyWithReload(() => import("@/pages/UserProfile")); -import { ROUTES } from "./routes"; - -// Backward compatibility alias +// Backward compatibility alias. export const Routes = ROUTES; export { ROUTES }; -const router = createBrowserRouter([ +/** + * Static route configuration. Exported so tests can assert on the tree shape + * (e.g. that `/auth/callback` stays outside the guest-only guard subtree) and + * so integration tests can drive a `createMemoryRouter` over the same tree. + */ +export const routeConfig: RouteObject[] = [ { path: "/", element: , @@ -52,30 +57,49 @@ const router = createBrowserRouter([ { path: Routes.AUTH, children: [ - { path: "", element: }, - { path: "admin", element: }, - { path: "signup", element: }, + // The OAuth callback must run regardless of the current session — an + // authenticated tab elsewhere must not block it from consuming its + // one-time OAuth state. Keep it outside the guest-only subtree. { path: "callback", element: }, + { + element: , + children: [ + { path: "", element: }, + { path: "admin", element: }, + { path: "signup", element: }, + ], + }, ], }, + { index: true, element: }, { - path: Routes.ROOT, + path: Routes.ENTRY, element: , children: [ { element: , children: [ - { path: "", element: }, { path: Routes.EXPLORE, element: }, - { path: Routes.ARCHIVED, element: }, { path: "u/:username", element: }, + { + element: , + children: [ + { path: Routes.HOME, element: }, + { path: Routes.ARCHIVED, element: }, + ], + }, ], }, - { path: Routes.ATTACHMENTS, element: }, - { path: Routes.INBOX, element: }, - { path: Routes.SETTING, element: }, { path: "memos/:uid", element: }, { path: "memos/shares/:token", element: }, + { + element: , + children: [ + { path: Routes.ATTACHMENTS, element: }, + { path: Routes.INBOX, element: }, + { path: Routes.SETTING, element: }, + ], + }, { path: "403", element: }, { path: "404", element: }, { path: "*", element: }, @@ -83,6 +107,8 @@ const router = createBrowserRouter([ }, ], }, -]); +]; + +const router = createBrowserRouter(routeConfig); export default router; diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts index 48991636..cde8aadf 100644 --- a/web/src/router/routes.ts +++ b/web/src/router/routes.ts @@ -1,5 +1,8 @@ export const ROUTES = { - ROOT: "/", + // Entry-only route. Hosts the landing redirect, never a business page. + ENTRY: "/", + // The authenticated user's primary workspace page. + HOME: "/home", ATTACHMENTS: "/attachments", INBOX: "/inbox", ARCHIVED: "/archived", diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index 7e1e00cb..c7f63a0f 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -1,50 +1,27 @@ import { clearAccessToken } from "@/auth-state"; import { ROUTES } from "@/router/routes"; +import { buildAuthRoute, isPublicRoute } from "./redirect-safety"; -const PUBLIC_ROUTES = [ - ROUTES.AUTH, // Authentication pages - ROUTES.EXPLORE, // Explore page - ROUTES.SHARED_MEMO + "/", // Shared memo pages (share-link viewer) - "/u/", // User profile pages (dynamic) - "/memos/", // Individual memo detail pages (dynamic) -] as const; - -export const AUTH_REDIRECT_PARAM = "redirect"; -export const AUTH_REASON_PARAM = "reason"; -export const AUTH_REASON_PROTECTED_MEMO = "protected-memo"; - -function isPublicRoute(path: string): boolean { - return PUBLIC_ROUTES.some((route) => path.startsWith(route)); -} - -export function getSafeRedirectPath(path: string | null | undefined): string | undefined { - if (!path) { - return undefined; - } - - if (!path.startsWith("/") || path.startsWith("//")) { - return undefined; - } - - return path; -} - -export function buildAuthRoute(options?: { redirect?: string | null; reason?: string | null }): string { - const searchParams = new URLSearchParams(); - const redirectPath = getSafeRedirectPath(options?.redirect); - - if (redirectPath) { - searchParams.set(AUTH_REDIRECT_PARAM, redirectPath); - } - - if (options?.reason) { - searchParams.set(AUTH_REASON_PARAM, options.reason); - } - - const search = searchParams.toString(); - return search ? `${ROUTES.AUTH}?${search}` : ROUTES.AUTH; -} +// Re-export the pure helpers so existing call sites (`@/utils/auth-redirect`) +// keep working without every caller switching to the new module. The side-effectful +// `redirectOnAuthFailure` lives here; pure logic lives in `./redirect-safety`. +export { + AUTH_REASON_PARAM, + AUTH_REASON_PROTECTED_MEMO, + AUTH_REDIRECT_PARAM, + buildAuthRoute, + getSafeRedirectPath, + isPublicRoute, +} from "./redirect-safety"; +/** + * Imperatively redirects the current document to the auth entry page, preserving + * the current URL as the `redirect` target. Intended for hard-fail auth paths + * (e.g. a refresh-token request returning 401 from a non-React context). + * + * No-ops when the user is already on an auth page or on a public page that + * does not require authentication, unless `forceRedirect` is set. + */ export function redirectOnAuthFailure( forceRedirect = false, options?: { diff --git a/web/src/utils/redirect-safety.ts b/web/src/utils/redirect-safety.ts new file mode 100644 index 00000000..bfc4741d --- /dev/null +++ b/web/src/utils/redirect-safety.ts @@ -0,0 +1,72 @@ +import { ROUTES } from "@/router/routes"; + +/** Query parameter used to preserve the intended destination across the auth flow. */ +export const AUTH_REDIRECT_PARAM = "redirect"; + +/** Query parameter used to surface why the user was sent to the auth page. */ +export const AUTH_REASON_PARAM = "reason"; + +/** Reason code signalling that the user hit a memo that requires authentication. */ +export const AUTH_REASON_PROTECTED_MEMO = "protected-memo"; + +/** + * Validates a post-authentication redirect target. + * + * Returns the path when it is a safe same-origin internal destination, otherwise `undefined`. + * Rejected targets include: non-string / empty, protocol-relative URLs (`//host`), absolute URLs, + * and any auth-family route (`/auth`, `/auth/callback`, …) which must not be a landing target + * after sign-in. + */ +export function getSafeRedirectPath(path: string | null | undefined): string | undefined { + if (!path) { + return undefined; + } + + if (!path.startsWith("/") || path.startsWith("//")) { + return undefined; + } + + // Never let a redirect target point back into the auth flow — it would either + // bounce the user in a guest/auth guard loop or hijack the OAuth callback. + if (path === ROUTES.AUTH || path.startsWith(`${ROUTES.AUTH}/`) || path.startsWith(`${ROUTES.AUTH}?`)) { + return undefined; + } + + return path; +} + +/** + * Builds a URL pointing at the auth entry page, optionally embedding a validated + * `redirect` target and a machine-readable `reason` code. + */ +export function buildAuthRoute(options?: { redirect?: string | null; reason?: string | null }): string { + const searchParams = new URLSearchParams(); + const redirectPath = getSafeRedirectPath(options?.redirect); + + if (redirectPath) { + searchParams.set(AUTH_REDIRECT_PARAM, redirectPath); + } + + if (options?.reason) { + searchParams.set(AUTH_REASON_PARAM, options.reason); + } + + const search = searchParams.toString(); + return search ? `${ROUTES.AUTH}?${search}` : ROUTES.AUTH; +} + +const PUBLIC_ROUTE_PREFIXES = [ + ROUTES.AUTH, // Authentication pages + ROUTES.EXPLORE, // Explore page + `${ROUTES.SHARED_MEMO}/`, // Shared memo pages (share-link viewer) + "/u/", // User profile pages (dynamic) + "/memos/", // Individual memo detail pages (dynamic) +] as const; + +/** + * Reports whether a given pathname corresponds to a page that unauthenticated + * visitors are allowed to view without being bounced to the auth page. + */ +export function isPublicRoute(path: string): boolean { + return PUBLIC_ROUTE_PREFIXES.some((route) => path.startsWith(route)); +} diff --git a/web/tests/auth-redirect.test.ts b/web/tests/auth-redirect.test.ts new file mode 100644 index 00000000..f0150e08 --- /dev/null +++ b/web/tests/auth-redirect.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/auth-state", () => ({ + clearAccessToken: vi.fn(), +})); + +import { clearAccessToken } from "@/auth-state"; +import { redirectOnAuthFailure } from "@/utils/auth-redirect"; + +const mockedClearAccessToken = vi.mocked(clearAccessToken); + +type NavigationStub = { replace: ReturnType; href: string }; + +function installLocation(href: string): NavigationStub { + const url = new URL(href); + const replace = vi.fn((next: string) => { + // Mirror real navigation: update the mutable href on subsequent inspection. + location.href = new URL(next, url).toString(); + }); + const location: NavigationStub = { replace, href: url.toString() }; + + Object.defineProperty(window, "location", { + configurable: true, + value: { + get href() { + return location.href; + }, + set href(value: string) { + location.href = value; + }, + pathname: url.pathname, + search: url.search, + hash: url.hash, + origin: url.origin, + replace, + }, + }); + + return location; +} + +describe("redirectOnAuthFailure", () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + }); + + afterEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + }); + + it("does nothing when the user is already on an /auth page", () => { + const nav = installLocation("http://localhost/auth?foo=bar"); + + redirectOnAuthFailure(); + + expect(nav.replace).not.toHaveBeenCalled(); + expect(mockedClearAccessToken).not.toHaveBeenCalled(); + }); + + it("does nothing on a public route by default", () => { + const nav = installLocation("http://localhost/explore"); + + redirectOnAuthFailure(); + + expect(nav.replace).not.toHaveBeenCalled(); + expect(mockedClearAccessToken).not.toHaveBeenCalled(); + }); + + it("clears the token and redirects to /auth on a protected route", () => { + const nav = installLocation("http://localhost/home?tab=pins#latest"); + + redirectOnAuthFailure(); + + expect(mockedClearAccessToken).toHaveBeenCalledTimes(1); + expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest"); + }); + + it("honours forceRedirect even on a public route", () => { + const nav = installLocation("http://localhost/explore"); + + redirectOnAuthFailure(true); + + expect(mockedClearAccessToken).toHaveBeenCalledTimes(1); + expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fexplore"); + }); + + it("embeds the reason parameter when provided", () => { + const nav = installLocation("http://localhost/home"); + + redirectOnAuthFailure(false, { reason: "protected-memo" }); + + expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome&reason=protected-memo"); + }); + + it("prefers an explicitly provided redirect target over the current location", () => { + const nav = installLocation("http://localhost/home"); + + redirectOnAuthFailure(false, { redirect: "/setting" }); + + expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fsetting"); + }); + + it("drops an unsafe redirect target silently", () => { + const nav = installLocation("http://localhost/home"); + + redirectOnAuthFailure(false, { redirect: "//evil.example/phish" }); + + expect(nav.replace).toHaveBeenCalledWith("/auth"); + }); +}); diff --git a/web/tests/guards.test.tsx b/web/tests/guards.test.tsx new file mode 100644 index 00000000..6f9c7897 --- /dev/null +++ b/web/tests/guards.test.tsx @@ -0,0 +1,202 @@ +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/useCurrentUser", () => ({ + __esModule: true, + default: vi.fn(), +})); + +import useCurrentUser from "@/hooks/useCurrentUser"; +import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards"; + +const mockedUseCurrentUser = vi.mocked(useCurrentUser); + +// Minimal User-like stand-in — guards only check truthiness on the value. +const fakeUser = { name: "users/steven" } as unknown as ReturnType; + +const LocationProbe = () => { + const location = useLocation(); + return
{`${location.pathname}${location.search}${location.hash}`}
; +}; + +const renderAt = (initialEntry: string, children: ReactNode) => + render({children}); + +describe("LandingRoute", () => { + it("sends an authenticated visitor from the entry to /home", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/", + + } /> + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home"); + }); + + it("sends an unauthenticated visitor from the entry to /explore", () => { + mockedUseCurrentUser.mockReturnValue(undefined); + + renderAt( + "/", + + } /> + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/explore"); + }); + + it("preserves the query string and hash when redirecting an authenticated visitor", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/?filter=tag:work&sort=desc#top", + + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home?filter=tag:work&sort=desc#top"); + }); + + it("preserves the query string and hash when redirecting an unauthenticated visitor", () => { + // Covers the regression in issue #5846: bookmarks pointing at `/?filter=...` + // must not drop their params on the trip through the landing redirect. + mockedUseCurrentUser.mockReturnValue(undefined); + + renderAt( + "/?filter=tag:work#latest", + + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/explore?filter=tag:work#latest"); + }); +}); + +describe("RequireAuthRoute", () => { + it("renders the protected content for authenticated users", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/home", + + }> + secret} /> + + , + ); + + expect(screen.getByTestId("protected")).toHaveTextContent("secret"); + }); + + it("redirects unauthenticated users to /auth with the preserved location", () => { + mockedUseCurrentUser.mockReturnValue(undefined); + + renderAt( + "/home?tab=pins#latest", + + }> + secret} /> + + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest"); + }); +}); + +describe("RequireGuestRoute", () => { + it("renders the auth page when no user is present", () => { + mockedUseCurrentUser.mockReturnValue(undefined); + + renderAt( + "/auth", + + }> + sign in} /> + + , + ); + + expect(screen.getByTestId("sign-in")).toHaveTextContent("sign in"); + }); + + it("redirects already-authenticated users to /home by default", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/auth", + + }> + sign in} /> + + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home"); + }); + + it("honours a safe redirect target from the query string", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/auth?redirect=%2Fsetting", + + }> + sign in} /> + + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/setting"); + }); + + it("ignores an auth-family redirect target and falls back to /home", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/auth?redirect=%2Fauth%2Fcallback", + + }> + sign in} /> + + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home"); + }); + + it("ignores an external redirect target and falls back to /home", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/auth?redirect=%2F%2Fevil.example%2Fphish", + + }> + sign in} /> + + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home"); + }); +}); diff --git a/web/tests/memo-content-security.test.mjs b/web/tests/memo-content-security.test.mjs deleted file mode 100644 index 6dcc3ff2..00000000 --- a/web/tests/memo-content-security.test.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import ReactMarkdown from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize from "rehype-sanitize"; -import remarkMath from "remark-math"; -import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "../src/components/MemoContent/constants.ts"; - -const TrustedIframe = (props) => { - if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) { - return null; - } - - return React.createElement("iframe", props); -}; - -const renderMemoContent = (content) => - renderToStaticMarkup( - React.createElement(ReactMarkdown, { - children: content, - remarkPlugins: [remarkMath], - rehypePlugins: [rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], [rehypeKatex, { throwOnError: false, strict: false }]], - components: { - iframe: TrustedIframe, - }, - }), - ); - -test("strips user-controlled inline styles from raw HTML spans", () => { - const html = renderMemoContent('overlay'); - - assert.match(html, /overlay<\/span>/); - assert.doesNotMatch(html, /style=/); - assert.doesNotMatch(html, /position:fixed/); -}); - -test("still renders KaTeX output after sanitizing math marker classes", () => { - const html = renderMemoContent("$L$"); - - assert.match(html, /class="katex"/); - assert.match(html, /class="katex-html"/); -}); - -test("allows trusted iframe providers only", () => { - assert.equal(isTrustedIframeSrc("https://www.youtube.com/embed/abc123"), true); - assert.equal(isTrustedIframeSrc("https://www.youtube-nocookie.com/embed/abc123?si=test"), true); - assert.equal(isTrustedIframeSrc("https://player.vimeo.com/video/123456"), true); - assert.equal(isTrustedIframeSrc("https://open.spotify.com/embed/track/123456"), true); - assert.equal(isTrustedIframeSrc("https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/123456"), true); - assert.equal(isTrustedIframeSrc("https://www.loom.com/embed/123456"), true); - assert.equal(isTrustedIframeSrc("https://www.google.com/maps/embed?pb=test"), true); - assert.equal(isTrustedIframeSrc("https://app.diagrams.net/?embed=1"), true); - assert.equal(isTrustedIframeSrc("https://www.draw.io/?embed=1"), true); - assert.equal(isTrustedIframeSrc("https://evil.example/embed/abc123"), false); -}); - -test("drops untrusted iframe embeds during rendering", () => { - const trusted = renderMemoContent(''); - const untrusted = renderMemoContent(''); - - assert.match(trusted, /'); + const untrusted = renderMemoContent(''); + + expect(trusted).toMatch(/