Unverified Commit 88cb58ab authored by boojack's avatar boojack Committed by GitHub

refactor(web/routing): guard-based auth flow, migrate tests to Vitest (#5848)

parent 587f5b1b
......@@ -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
......
......@@ -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/
......@@ -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": [
......
......@@ -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: {}
......
......@@ -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]);
......
......@@ -42,7 +42,7 @@ export const Tag: React.FC<TagProps> = ({ "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 }]));
......
......@@ -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: <LibraryIcon className="w-6 h-auto shrink-0" />,
};
......@@ -77,7 +77,7 @@ const Navigation = (props: Props) => {
return (
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4", className)}>
<div className="w-full px-1 py-1 flex flex-col justify-start items-start space-y-2 overflow-auto overflow-x-hidden shrink">
<NavLink className="mb-3 cursor-default" to={currentUser ? Routes.ROOT : Routes.EXPLORE}>
<NavLink className="mb-3 cursor-default" to={currentUser ? Routes.HOME : Routes.EXPLORE}>
<MemosLogo collapsed={collapsed} />
</NavLink>
<TooltipProvider>
......
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(
{
......
......@@ -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.",
......
......@@ -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";
......
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);
}
......
......@@ -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.",
......
......@@ -29,6 +29,7 @@ const Home = () => {
orderBy={orderBy}
filter={memoFilter}
enabled={isInitialized}
showMemoEditor
/>
</div>
);
......
......@@ -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<IdentityProvider[]>([]);
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 () => {
......
......@@ -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",
......
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 (
<Navigate
to={{
pathname: target,
search: location.search,
hash: location.hash,
}}
replace
/>
);
};
/**
* 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 <Navigate to={buildAuthRoute({ redirect })} replace />;
}
return <Outlet />;
};
/**
* 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 <Navigate to={redirectTarget || ROUTES.HOME} replace />;
}
return <Outlet />;
};
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<T extends React.ComponentType>(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: <App />,
......@@ -52,30 +57,49 @@ const router = createBrowserRouter([
{
path: Routes.AUTH,
children: [
{ path: "", element: <SignIn /> },
{ path: "admin", element: <AdminSignIn /> },
{ path: "signup", element: <SignUp /> },
// 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: <AuthCallback /> },
{
element: <RequireGuestRoute />,
children: [
{ path: "", element: <SignIn /> },
{ path: "admin", element: <AdminSignIn /> },
{ path: "signup", element: <SignUp /> },
],
},
],
},
{ index: true, element: <LandingRoute /> },
{
path: Routes.ROOT,
path: Routes.ENTRY,
element: <RootLayout />,
children: [
{
element: <MainLayout />,
children: [
{ path: "", element: <Home /> },
{ path: Routes.EXPLORE, element: <Explore /> },
{ path: Routes.ARCHIVED, element: <Archived /> },
{ path: "u/:username", element: <UserProfile /> },
{
element: <RequireAuthRoute />,
children: [
{ path: Routes.HOME, element: <Home /> },
{ path: Routes.ARCHIVED, element: <Archived /> },
],
},
],
},
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
{ path: Routes.INBOX, element: <Inboxes /> },
{ path: Routes.SETTING, element: <Setting /> },
{ path: "memos/:uid", element: <MemoDetail /> },
{ path: "memos/shares/:token", element: <MemoDetail /> },
{
element: <RequireAuthRoute />,
children: [
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
{ path: Routes.INBOX, element: <Inboxes /> },
{ path: Routes.SETTING, element: <Setting /> },
],
},
{ path: "403", element: <PermissionDenied /> },
{ path: "404", element: <NotFound /> },
{ path: "*", element: <NotFound /> },
......@@ -83,6 +107,8 @@ const router = createBrowserRouter([
},
],
},
]);
];
const router = createBrowserRouter(routeConfig);
export default router;
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",
......
import { clearAccessToken } from "@/auth-state";
import { ROUTES } from "@/router/routes";
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;
}
import { buildAuthRoute, isPublicRoute } from "./redirect-safety";
// 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?: {
......
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));
}
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<typeof vi.fn>; 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");
});
});
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<typeof useCurrentUser>;
const LocationProbe = () => {
const location = useLocation();
return <div data-testid="location">{`${location.pathname}${location.search}${location.hash}`}</div>;
};
const renderAt = (initialEntry: string, children: ReactNode) =>
render(<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>);
describe("LandingRoute", () => {
it("sends an authenticated visitor from the entry to /home", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/",
<Routes>
<Route path="/" element={<LandingRoute />} />
<Route path="/home" element={<LocationProbe />} />
<Route path="/explore" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/home");
});
it("sends an unauthenticated visitor from the entry to /explore", () => {
mockedUseCurrentUser.mockReturnValue(undefined);
renderAt(
"/",
<Routes>
<Route path="/" element={<LandingRoute />} />
<Route path="/home" element={<LocationProbe />} />
<Route path="/explore" element={<LocationProbe />} />
</Routes>,
);
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",
<Routes>
<Route path="/" element={<LandingRoute />} />
<Route path="/home" element={<LocationProbe />} />
</Routes>,
);
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",
<Routes>
<Route path="/" element={<LandingRoute />} />
<Route path="/explore" element={<LocationProbe />} />
</Routes>,
);
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",
<Routes>
<Route element={<RequireAuthRoute />}>
<Route path="/home" element={<div data-testid="protected">secret</div>} />
</Route>
</Routes>,
);
expect(screen.getByTestId("protected")).toHaveTextContent("secret");
});
it("redirects unauthenticated users to /auth with the preserved location", () => {
mockedUseCurrentUser.mockReturnValue(undefined);
renderAt(
"/home?tab=pins#latest",
<Routes>
<Route element={<RequireAuthRoute />}>
<Route path="/home" element={<div data-testid="protected">secret</div>} />
</Route>
<Route path="/auth" element={<LocationProbe />} />
</Routes>,
);
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",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div data-testid="sign-in">sign in</div>} />
</Route>
</Routes>,
);
expect(screen.getByTestId("sign-in")).toHaveTextContent("sign in");
});
it("redirects already-authenticated users to /home by default", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/auth",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div>sign in</div>} />
</Route>
<Route path="/home" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/home");
});
it("honours a safe redirect target from the query string", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/auth?redirect=%2Fsetting",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div>sign in</div>} />
</Route>
<Route path="/setting" element={<LocationProbe />} />
<Route path="/home" element={<LocationProbe />} />
</Routes>,
);
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",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div>sign in</div>} />
</Route>
<Route path="/home" element={<LocationProbe />} />
</Routes>,
);
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",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div>sign in</div>} />
</Route>
<Route path="/home" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/home");
});
});
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('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
assert.match(html, /<span>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('<iframe src="https://www.youtube.com/embed/abc123" title="demo"></iframe>');
const untrusted = renderMemoContent('<iframe src="https://evil.example/embed/abc123" title="demo"></iframe>');
assert.match(trusted, /<iframe/);
assert.match(trusted, /youtube\.com\/embed\/abc123/);
assert.doesNotMatch(untrusted, /<iframe/);
});
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 { describe, expect, it } from "vitest";
import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "@/components/MemoContent/constants";
type IframeProps = React.ComponentProps<"iframe">;
const TrustedIframe = (props: IframeProps) => {
if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) {
return null;
}
return <iframe {...props} />;
};
const renderMemoContent = (content: string): string =>
renderToStaticMarkup(
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], [rehypeKatex, { throwOnError: false, strict: false }]]}
components={{ iframe: TrustedIframe }}
>
{content}
</ReactMarkdown>,
);
describe("memo content sanitization", () => {
it("strips user-controlled inline styles from raw HTML spans", () => {
const html = renderMemoContent('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
expect(html).toMatch(/<span>overlay<\/span>/);
expect(html).not.toMatch(/style=/);
expect(html).not.toMatch(/position:fixed/);
});
it("still renders KaTeX output after sanitizing math marker classes", () => {
const html = renderMemoContent("$L$");
expect(html).toMatch(/class="katex"/);
expect(html).toMatch(/class="katex-html"/);
});
});
describe("trusted iframe providers", () => {
it("accepts trusted providers only", () => {
expect(isTrustedIframeSrc("https://www.youtube.com/embed/abc123")).toBe(true);
expect(isTrustedIframeSrc("https://www.youtube-nocookie.com/embed/abc123?si=test")).toBe(true);
expect(isTrustedIframeSrc("https://player.vimeo.com/video/123456")).toBe(true);
expect(isTrustedIframeSrc("https://open.spotify.com/embed/track/123456")).toBe(true);
expect(isTrustedIframeSrc("https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/123456")).toBe(true);
expect(isTrustedIframeSrc("https://www.loom.com/embed/123456")).toBe(true);
expect(isTrustedIframeSrc("https://www.google.com/maps/embed?pb=test")).toBe(true);
expect(isTrustedIframeSrc("https://app.diagrams.net/?embed=1")).toBe(true);
expect(isTrustedIframeSrc("https://www.draw.io/?embed=1")).toBe(true);
expect(isTrustedIframeSrc("https://evil.example/embed/abc123")).toBe(false);
});
it("drops untrusted iframe embeds during rendering", () => {
const trusted = renderMemoContent('<iframe src="https://www.youtube.com/embed/abc123" title="demo"></iframe>');
const untrusted = renderMemoContent('<iframe src="https://evil.example/embed/abc123" title="demo"></iframe>');
expect(trusted).toMatch(/<iframe/);
expect(trusted).toMatch(/youtube\.com\/embed\/abc123/);
expect(untrusted).not.toMatch(/<iframe/);
});
});
import { describe, expect, it } from "vitest";
import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath, isPublicRoute } from "@/utils/redirect-safety";
describe("getSafeRedirectPath", () => {
it("accepts safe same-origin internal paths", () => {
expect(getSafeRedirectPath("/home")).toBe("/home");
expect(getSafeRedirectPath("/setting")).toBe("/setting");
expect(getSafeRedirectPath("/memos/abc")).toBe("/memos/abc");
expect(getSafeRedirectPath("/explore?foo=1")).toBe("/explore?foo=1");
expect(getSafeRedirectPath("/explore#anchor")).toBe("/explore#anchor");
});
it("rejects empty and non-string input", () => {
expect(getSafeRedirectPath(undefined)).toBeUndefined();
expect(getSafeRedirectPath(null)).toBeUndefined();
expect(getSafeRedirectPath("")).toBeUndefined();
});
it("rejects non-internal targets", () => {
expect(getSafeRedirectPath("//evil.example")).toBeUndefined();
expect(getSafeRedirectPath("https://evil.example")).toBeUndefined();
expect(getSafeRedirectPath("http://evil.example/home")).toBeUndefined();
expect(getSafeRedirectPath("javascript:alert(1)")).toBeUndefined();
expect(getSafeRedirectPath("home")).toBeUndefined();
});
it("rejects auth-family targets", () => {
expect(getSafeRedirectPath("/auth")).toBeUndefined();
expect(getSafeRedirectPath("/auth/callback")).toBeUndefined();
expect(getSafeRedirectPath("/auth/signup")).toBeUndefined();
expect(getSafeRedirectPath("/auth?code=abc")).toBeUndefined();
});
it("does not false-match auth-like paths", () => {
expect(getSafeRedirectPath("/authors")).toBe("/authors");
});
});
describe("buildAuthRoute", () => {
it("embeds only safe redirect targets", () => {
expect(buildAuthRoute({ redirect: "/home" })).toBe("/auth?redirect=%2Fhome");
expect(buildAuthRoute({ redirect: "//evil.example" })).toBe("/auth");
expect(buildAuthRoute({ redirect: "/auth/callback" })).toBe("/auth");
expect(buildAuthRoute({ redirect: null })).toBe("/auth");
});
it("preserves the reason parameter", () => {
expect(buildAuthRoute({ reason: "protected-memo" })).toBe("/auth?reason=protected-memo");
expect(buildAuthRoute({ redirect: "/memos/abc", reason: "protected-memo" })).toBe(
"/auth?redirect=%2Fmemos%2Fabc&reason=protected-memo",
);
});
it("exposes the canonical redirect query key", () => {
expect(AUTH_REDIRECT_PARAM).toBe("redirect");
});
});
describe("isPublicRoute", () => {
it("identifies anonymous-accessible page prefixes", () => {
expect(isPublicRoute("/auth")).toBe(true);
expect(isPublicRoute("/auth/signup")).toBe(true);
expect(isPublicRoute("/explore")).toBe(true);
expect(isPublicRoute("/memos/abc")).toBe(true);
expect(isPublicRoute("/memos/shares/abc")).toBe(true);
expect(isPublicRoute("/u/steven")).toBe(true);
});
it("treats authenticated-only pages as non-public", () => {
expect(isPublicRoute("/home")).toBe(false);
expect(isPublicRoute("/setting")).toBe(false);
expect(isPublicRoute("/inbox")).toBe(false);
expect(isPublicRoute("/attachments")).toBe(false);
expect(isPublicRoute("/archived")).toBe(false);
});
});
import { isValidElement } from "react";
import type { RouteObject } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { routeConfig, ROUTES } from "@/router";
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
// Walk the nested route config and find the first route with the given path,
// starting from the provided roots. Returns undefined if nothing matches.
function findByPath(routes: RouteObject[], path: string): RouteObject | undefined {
for (const route of routes) {
if (route.path === path) return route;
const hit = route.children ? findByPath(route.children, path) : undefined;
if (hit) return hit;
}
return undefined;
}
function elementType(route: RouteObject | undefined): unknown {
if (!route?.element || !isValidElement(route.element)) return undefined;
return route.element.type;
}
function hasAncestorOfType(routes: RouteObject[], path: string, guardType: unknown): boolean {
const walk = (subtree: RouteObject[], ancestorGuards: unknown[]): boolean => {
for (const route of subtree) {
const nextAncestors = [...ancestorGuards];
const type = elementType(route);
if (type) nextAncestors.push(type);
if (route.path === path) {
return nextAncestors.includes(guardType);
}
if (route.children && walk(route.children, nextAncestors)) {
return true;
}
}
return false;
};
return walk(routes, []);
}
describe("router configuration", () => {
it("mounts the LandingRoute at the entry index", () => {
const root = routeConfig[0];
const indexRoute = root.children?.find((r) => r.index);
expect(elementType(indexRoute)).toBe(LandingRoute);
});
it("keeps /auth/callback outside the guest-only guard", () => {
// Regression guard for issue #5846 follow-up: an authenticated tab elsewhere
// must not short-circuit the OAuth callback via RequireGuestRoute.
expect(hasAncestorOfType(routeConfig, "callback", RequireGuestRoute)).toBe(false);
});
it("wraps the remaining /auth children in RequireGuestRoute", () => {
for (const path of ["", "admin", "signup"]) {
expect(hasAncestorOfType(routeConfig, path, RequireGuestRoute)).toBe(true);
}
});
it("wraps authenticated-only pages in RequireAuthRoute", () => {
for (const path of [ROUTES.HOME, ROUTES.ARCHIVED, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(true);
}
});
it("leaves public pages outside RequireAuthRoute", () => {
for (const path of [ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) {
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(false);
}
});
it("exposes an accessible /auth/callback route definition", () => {
expect(findByPath(routeConfig, "callback")).toBeTruthy();
});
});
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// With `globals: false`, @testing-library/react does not auto-register a
// cleanup hook, so unmount rendered trees between tests explicitly. This keeps
// `screen.getByTestId` from seeing DOM from prior tests in the same file.
afterEach(() => {
cleanup();
});
// Defensive shim: `@/auth-state` constructs a BroadcastChannel at module load
// to coordinate token refreshes across tabs. jsdom historically has not shipped
// BroadcastChannel, so any future test that transitively imports auth-state
// would otherwise throw. Current tests avoid that import path on purpose, but
// installing the shim keeps authoring new tests frictionless. No-op when jsdom
// already provides an implementation.
if (typeof globalThis.BroadcastChannel === "undefined") {
class NoopBroadcastChannel {
readonly name: string;
onmessage: ((event: MessageEvent) => void) | null = null;
constructor(name: string) {
this.name = name;
}
postMessage(_data: unknown): void {}
close(): void {}
addEventListener(): void {}
removeEventListener(): void {}
dispatchEvent(): boolean {
return true;
}
}
// @ts-expect-error — attach the shim to the global scope for tests.
globalThis.BroadcastChannel = NoopBroadcastChannel;
}
import react from "@vitejs/plugin-react";
import { resolve } from "path";
import { defineConfig } from "vitest/config";
// Vitest configuration. Kept separate from `vite.config.mts` so the dev/build
// pipelines stay lean and so tests can opt into jsdom + @testing-library
// without dragging them into production bundles.
export default defineConfig({
plugins: [react()],
resolve: {
// Keep in sync with the `@/` alias declared in `vite.config.mts` so that
// test-time module resolution matches production/build.
alias: {
"@/": `${resolve(__dirname, "src")}/`,
},
},
test: {
environment: "jsdom",
setupFiles: ["./tests/setup.ts"],
include: ["tests/**/*.test.{ts,tsx}"],
// Keep each test hermetic:
// - mockReset clears call history and resets implementations for vi.fn()s,
// so module-level mocks (e.g. useCurrentUser) don't leak between tests.
// - restoreMocks additionally restores original implementations for spies.
mockReset: true,
restoreMocks: true,
},
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment