fix: replace frontend with Rust OPAQUE API + Flutter keypad UI

- Full OPAQUE auth flow via WASM client SDK (client-wasm crate)
- New user: Key Register → Key Login → Code Register (icon selection) → done
- Existing user: Key Login → get login-data → icon keypad → Code Login → done
- Icon-based keypad matching Flutter design:
  - 2 cols portrait, 3 cols landscape
  - Key tiles with 3-col sub-grid of icons
  - Navy border press feedback
  - Dot display with backspace + submit
- SVGs rendered as-is (no color manipulation)
- SusiPage with Login/Signup tabs
- LoginKeypadPage and SignupKeypadPage for code flows
- Secret key display/copy on signup
- Unit tests for Keypad component
- WASM pkg bundled locally (no external dep)
This commit is contained in:
2026-01-29 17:05:32 +00:00
parent 7494bf7520
commit 5c3217e3d5
36 changed files with 2045 additions and 1149 deletions

View File

@@ -1,17 +1,18 @@
# Stage 1: Build # Stage 1: Build
FROM oven/bun:1 AS build FROM oven/bun:1 AS build
WORKDIR /app WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile ARG VITE_NKODE_API_URL
COPY . .
ARG VITE_NKODE_API_URL=https://api.nkode.donovankelly.xyz
ENV VITE_NKODE_API_URL=$VITE_NKODE_API_URL ENV VITE_NKODE_API_URL=$VITE_NKODE_API_URL
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile || bun install
COPY . .
RUN bun run build RUN bun run build
# Stage 2: Serve # Stage 2: Serve
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
# SPA routing: all paths → index.html
RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n index index.html;\n location / {\n try_files $uri $uri/ /index.html;\n }\n}\n' > /etc/nginx/conf.d/default.conf RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n index index.html;\n location / {\n try_files $uri $uri/ /index.html;\n }\n}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

237
bun.lock
View File

@@ -7,11 +7,17 @@
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^6.30.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.1.0",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
@@ -19,15 +25,26 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"react-router-dom": "^7.13.0", "jsdom": "^27.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^7.2.4",
"vitest": "^4.0.18",
}, },
}, },
}, },
"packages": { "packages": {
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.6", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.4" } }, "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
"@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
@@ -60,12 +77,26 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.26", "", {}, "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
@@ -136,6 +167,8 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -154,8 +187,12 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
"@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg=="],
@@ -206,6 +243,36 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/core": ["@swc/core@1.15.11", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.11", "@swc/core-darwin-x64": "1.15.11", "@swc/core-linux-arm-gnueabihf": "1.15.11", "@swc/core-linux-arm64-gnu": "1.15.11", "@swc/core-linux-arm64-musl": "1.15.11", "@swc/core-linux-x64-gnu": "1.15.11", "@swc/core-linux-x64-musl": "1.15.11", "@swc/core-win32-arm64-msvc": "1.15.11", "@swc/core-win32-ia32-msvc": "1.15.11", "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.11", "", { "os": "linux", "cpu": "arm" }, "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.11", "", { "os": "linux", "cpu": "x64" }, "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.11", "", { "os": "win32", "cpu": "x64" }, "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@swc/wasm": ["@swc/wasm@1.15.11", "", {}, "sha512-230rdYZf8ux3nIwISOQNCFrxzxpL/UFY4Khv/3UsvpEdo709j/+Tg80yXWW3DXETeZNPBV72QpvEBhXsl7Lc9g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@@ -236,6 +303,16 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -244,11 +321,15 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
@@ -276,20 +357,44 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="],
"@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="],
"@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="],
"@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="],
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
@@ -298,6 +403,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], "caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -308,22 +415,38 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="], "electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -348,8 +471,12 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -382,16 +509,26 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -400,6 +537,8 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -442,10 +581,16 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -456,6 +601,8 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -464,10 +611,14 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -476,44 +627,76 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="], "react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="],
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="], "react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="], "rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
"tldts-core": ["tldts-core@7.0.19", "", {}, "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="],
"tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -528,12 +711,36 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-plugin-top-level-await": ["vite-plugin-top-level-await@1.6.0", "", { "dependencies": { "@rollup/plugin-virtual": "^3.0.2", "@swc/core": "^1.12.14", "@swc/wasm": "^1.12.14", "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww=="],
"vite-plugin-wasm": ["vite-plugin-wasm@3.5.0", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ=="],
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -542,6 +749,8 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -558,12 +767,20 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
} }
} }

View File

@@ -2,11 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/n.png" type="image/png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nKode — Passwordless Auth</title> <meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <meta name="apple-mobile-web-app-title" content="nKode" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <title>nKode</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,22 +1,29 @@
{ {
"name": "nkode-web", "name": "nkode-web",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^6.30.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.1.0",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
@@ -24,10 +31,11 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"react-router-dom": "^7.13.0", "jsdom": "^27.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4" "vite": "^7.2.4",
"vitest": "^4.0.18"
} }
} }

View File

@@ -96,6 +96,14 @@ function getStringFromWasm0(ptr, len) {
return decodeText(ptr, len); return decodeText(ptr, len);
} }
let cachedUint32ArrayMemory0 = null;
function getUint32ArrayMemory0() {
if (cachedUint32ArrayMemory0 === null || cachedUint32ArrayMemory0.byteLength === 0) {
cachedUint32ArrayMemory0 = new Uint32Array(wasm.memory.buffer);
}
return cachedUint32ArrayMemory0;
}
let cachedUint8ArrayMemory0 = null; let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() { function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
@@ -145,6 +153,13 @@ function makeMutClosure(arg0, arg1, dtor, f) {
return real; return real;
} }
function passArray32ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 4, 4) >>> 0;
getUint32ArrayMemory0().set(arg, ptr / 4);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passArray8ToWasm0(arg, malloc) { function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0; const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1); getUint8ArrayMemory0().set(arg, ptr / 1);
@@ -189,6 +204,12 @@ function passStringToWasm0(arg, malloc, realloc) {
return ptr; return ptr;
} }
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode(); cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072; const MAX_SAFARI_DECODE_BYTES = 2146435072;
@@ -400,6 +421,56 @@ export class NKodeClient {
const ret = wasm.nkodeclient_updateLoginData(this.__wbg_ptr, ptr0, len0); const ret = wasm.nkodeclient_updateLoginData(this.__wbg_ptr, ptr0, len0);
return ret; return ret;
} }
/**
* Decipher key selections into OPAQUE passcode bytes for code login.
* Call this after the user taps their nKode sequence on the keypad.
*
* @param userId - The user's ID
* @param secretKeyHex - The user's secret key as hex
* @param loginDataBytes - Raw JSON bytes of login data (from server)
* @param keySelections - Array of key indices the user tapped
* @returns Uint8Array - The passcode bytes to pass to loginCode()
* @param {string} secret_key_hex
* @param {string} login_data_json
* @param {Uint32Array} key_selections
* @returns {Uint8Array}
*/
decipherSelection(secret_key_hex, login_data_json, key_selections) {
const ptr0 = passStringToWasm0(secret_key_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(login_data_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passArray32ToWasm0(key_selections, wasm.__wbindgen_malloc);
const len2 = WASM_VECTOR_LEN;
const ret = wasm.nkodeclient_decipherSelection(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v4 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v4;
}
/**
* Prepare for code login: fetch login data, reconstruct keypad, and fetch icons.
* Returns the keypad configuration with icons for UI display.
*
* Also stores the raw login data JSON internally for decipherSelection().
*
* @param userId - The user's ID
* @param secretKeyHex - The user's secret key as hex string
* @returns Promise<CodeLoginData> - { keypadIndices, propertiesPerKey, numberOfKeys, mask, icons, loginDataJson }
* @param {string} user_id
* @param {string} secret_key_hex
* @returns {Promise<any>}
*/
prepareCodeLogin(user_id, secret_key_hex) {
const ptr0 = passStringToWasm0(user_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(secret_key_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.nkodeclient_prepareCodeLogin(this.__wbg_ptr, ptr0, len0, ptr1, len1);
return ret;
}
/** /**
* Generate a new random 16-byte secret key, returned as a hex string (32 chars). * Generate a new random 16-byte secret key, returned as a hex string (32 chars).
* @returns {string} * @returns {string}
@@ -416,6 +487,52 @@ export class NKodeClient {
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
} }
} }
/**
* Prepare icons for code registration (requires active key session).
* Fetches icons from the server and randomizes their names via ChaCha20.
* Stores intermediate state internally for completeCodeRegistration().
*
* @returns Promise<IconsResponse> - JSON: { icons: [{ file_name, file_type, img_data }] }
* @returns {Promise<any>}
*/
prepareCodeRegistration() {
const ret = wasm.nkodeclient_prepareCodeRegistration(this.__wbg_ptr);
return ret;
}
/**
* Complete code registration after icon selection.
* Enciphers the selection, registers OPAQUE code auth, and stores login data.
*
* @param selectedIndices - Array of icon indices the user selected (global indices, not key indices)
* @returns Promise<void>
* @param {Uint32Array} selected_indices
* @returns {Promise<void>}
*/
completeCodeRegistration(selected_indices) {
const ptr0 = passArray32ToWasm0(selected_indices, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.nkodeclient_completeCodeRegistration(this.__wbg_ptr, ptr0, len0);
return ret;
}
/**
* Complete code registration with email (full version).
* Enciphers the selection, registers OPAQUE code auth, and stores login data on server.
*
* @param email - User's email address
* @param selectedIndices - Uint32Array of icon indices the user selected
* @returns Promise<void>
* @param {string} email
* @param {Uint32Array} selected_indices
* @returns {Promise<void>}
*/
completeCodeRegistrationWithEmail(email, selected_indices) {
const ptr0 = passStringToWasm0(email, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray32ToWasm0(selected_indices, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.nkodeclient_completeCodeRegistrationWithEmail(this.__wbg_ptr, ptr0, len0, ptr1, len1);
return ret;
}
/** /**
* Create a new client pointed at the given nKode server base URL. * Create a new client pointed at the given nKode server base URL.
* @param {string} base_url * @param {string} base_url
@@ -567,6 +684,10 @@ export function __wbg_fetch_8119fbf8d0e4f4d1(arg0, arg1) {
return ret; return ret;
}; };
export function __wbg_getRandomValues_1c61fac11405ffdc() { return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments) };
export function __wbg_getRandomValues_b8f5dbd5f3995a9e() { return handleError(function (arg0, arg1) { export function __wbg_getRandomValues_b8f5dbd5f3995a9e() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1); arg0.getRandomValues(arg1);
}, arguments) }; }, arguments) };
@@ -816,12 +937,6 @@ export function __wbg_versions_c01dfd4722a88165(arg0) {
return ret; return ret;
}; };
export function __wbindgen_cast_151ffb1b798ab8ff(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 76, function: Function { arguments: [Externref], shim_idx: 77, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h28b97059ae600264, wasm_bindgen__convert__closures_____invoke__h8f97ce5df83102bb);
return ret;
};
export function __wbindgen_cast_2241b6af4c4b2941(arg0, arg1) { export function __wbindgen_cast_2241b6af4c4b2941(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`. // Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1); const ret = getStringFromWasm0(arg0, arg1);
@@ -852,6 +967,12 @@ export function __wbindgen_cast_d6cd19b81560fd6e(arg0) {
return ret; return ret;
}; };
export function __wbindgen_cast_f2cc0f2a96e2ef5b(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { dtor_idx: 115, function: Function { arguments: [Externref], shim_idx: 116, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h28b97059ae600264, wasm_bindgen__convert__closures_____invoke__h8f97ce5df83102bb);
return ret;
};
export function __wbindgen_init_externref_table() { export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_externrefs; const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4); const offset = table.grow(4);

View File

@@ -1,41 +1,41 @@
import { lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { AuthProvider, useAuth } from './hooks/useAuth'
import { Layout } from '@/components/Layout'; import Layout from './components/Layout'
import { AuthContext, useAuthState } from '@/hooks/useAuth'; import SusiPage from './pages/SusiPage'
import { ROUTES } from '@/lib/types'; import LoginKeypadPage from './pages/LoginKeypadPage'
import SignupKeypadPage from './pages/SignupKeypadPage'
import HomePage from './pages/HomePage'
import NotFoundPage from './pages/NotFoundPage'
const HomePage = lazy(() => import('@/pages/HomePage').then((m) => ({ default: m.HomePage }))); function ProtectedRoute({ children }: { children: React.ReactNode }) {
const LoginPage = lazy(() => import('@/pages/LoginPage').then((m) => ({ default: m.LoginPage }))); const { isAuthenticated } = useAuth()
const SignupPage = lazy(() => import('@/pages/SignupPage').then((m) => ({ default: m.SignupPage }))); if (!isAuthenticated) return <Navigate to="/" replace />
const AdminPage = lazy(() => import('@/pages/AdminPage').then((m) => ({ default: m.AdminPage }))); return <>{children}</>
const DeveloperPage = lazy(() => import('@/pages/DeveloperPage').then((m) => ({ default: m.DeveloperPage }))); }
function Loading() { function GuestRoute({ children }: { children: React.ReactNode }) {
return ( const { isAuthenticated } = useAuth()
<div className="flex items-center justify-center min-h-[50vh]"> if (isAuthenticated) return <Navigate to="/home" replace />
<div className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /> return <>{children}</>
</div>
);
} }
export default function App() { export default function App() {
const auth = useAuthState();
return ( return (
<AuthContext.Provider value={auth}> <AuthProvider>
<BrowserRouter>
<Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path={ROUTES.HOME} element={<HomePage />} /> {/* Guest routes */}
<Route path={ROUTES.LOGIN} element={<LoginPage />} /> <Route path="/" element={<GuestRoute><SusiPage /></GuestRoute>} />
<Route path={ROUTES.SIGNUP} element={<SignupPage />} /> <Route path="/login-keypad" element={<GuestRoute><LoginKeypadPage /></GuestRoute>} />
<Route path={ROUTES.ADMIN} element={<AdminPage />} /> <Route path="/signup-keypad" element={<GuestRoute><SignupKeypadPage /></GuestRoute>} />
<Route path={ROUTES.DEVELOPER} element={<DeveloperPage />} />
{/* Protected routes */}
<Route path="/home" element={<ProtectedRoute><HomePage /></ProtectedRoute>} />
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>
</Suspense> </AuthProvider>
</BrowserRouter> )
</AuthContext.Provider>
);
} }

View File

@@ -0,0 +1,223 @@
/**
* Auth flow integration tests — verify the sequence of API calls
* and navigation for login and signup flows.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import SusiPage from '../pages/SusiPage'
import * as api from '../services/api'
// Mock the entire api module
vi.mock('../services/api', () => ({
loginKey: vi.fn(),
prepareCodeLogin: vi.fn(),
generateSecretKey: vi.fn(),
registerKey: vi.fn(),
prepareCodeRegistration: vi.fn(),
}))
// Mock useNavigate
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useNavigate: () => mockNavigate,
}
})
function renderSusiPage() {
return render(
<MemoryRouter>
<SusiPage />
</MemoryRouter>,
)
}
describe('Auth Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Login flow', () => {
it('calls loginKey → prepareCodeLogin → navigates to /login-keypad', async () => {
const user = userEvent.setup()
const mockSession = {
sessionId: 'sess-123',
userId: 'user-abc',
createdAt: '2025-01-01',
expiresAt: '2025-01-02',
}
const mockCodeLoginData = {
keypadIndices: [0, 1, 2],
propertiesPerKey: 3,
numberOfKeys: 6,
mask: [1, 0, 1],
icons: [],
loginDataJson: '{}',
}
vi.mocked(api.loginKey).mockResolvedValue(mockSession)
vi.mocked(api.prepareCodeLogin).mockResolvedValue(mockCodeLoginData)
renderSusiPage()
// Fill in email
await user.type(screen.getByPlaceholderText('email'), 'test@example.com')
// Fill in secret key (32 hex chars)
await user.type(
screen.getByPlaceholderText('secret key (32 hex chars)'),
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
)
// Submit (find the submit button, not the tab)
const loginButtons = screen.getAllByRole('button', { name: /login/i })
const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')!
await user.click(submitBtn)
await waitFor(() => {
// Step 1: loginKey called with email + key
expect(api.loginKey).toHaveBeenCalledWith(
'test@example.com',
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
)
})
await waitFor(() => {
// Step 2: prepareCodeLogin called with userId + key
expect(api.prepareCodeLogin).toHaveBeenCalledWith(
'user-abc',
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
)
})
await waitFor(() => {
// Step 3: navigate to /login-keypad with state
expect(mockNavigate).toHaveBeenCalledWith('/login-keypad', {
state: {
email: 'test@example.com',
secretKeyHex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
userId: 'user-abc',
codeLoginData: mockCodeLoginData,
},
})
})
})
it('shows error on login failure', async () => {
const user = userEvent.setup()
vi.mocked(api.loginKey).mockRejectedValue(new Error('Invalid credentials'))
renderSusiPage()
await user.type(screen.getByPlaceholderText('email'), 'test@example.com')
await user.type(
screen.getByPlaceholderText('secret key (32 hex chars)'),
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
)
const loginButtons = screen.getAllByRole('button', { name: /login/i })
const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')!
await user.click(submitBtn)
await waitFor(() => {
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
})
})
})
describe('Signup flow', () => {
it('calls generateSecretKey → registerKey → loginKey → prepareCodeRegistration → navigates to /signup-keypad', async () => {
const user = userEvent.setup()
const mockSecretKey = 'deadbeef12345678deadbeef12345678'
const mockSession = {
sessionId: 'sess-456',
userId: 'user-xyz',
createdAt: '2025-01-01',
expiresAt: '2025-01-02',
}
const mockIcons = {
icons: [
{ file_name: 'icon1.svg', file_type: 'svg', img_data: '<svg></svg>' },
],
}
vi.mocked(api.generateSecretKey).mockReturnValue(mockSecretKey)
vi.mocked(api.registerKey).mockResolvedValue(undefined)
vi.mocked(api.loginKey).mockResolvedValue(mockSession)
vi.mocked(api.prepareCodeRegistration).mockResolvedValue(mockIcons)
renderSusiPage()
// Switch to signup tab
await user.click(screen.getByRole('button', { name: /sign up/i }))
// Fill in email
await user.type(screen.getByPlaceholderText('email'), 'newuser@example.com')
// Submit — the submit button in signup tab also says "Sign Up"
// We need the form submit button, not the tab button
const buttons = screen.getAllByRole('button', { name: /sign up/i })
// The last one is the form submit button
const submitBtn = buttons[buttons.length - 1]
await user.click(submitBtn)
await waitFor(() => {
// Step 1: generateSecretKey
expect(api.generateSecretKey).toHaveBeenCalled()
})
await waitFor(() => {
// Step 2: registerKey
expect(api.registerKey).toHaveBeenCalledWith('newuser@example.com', mockSecretKey)
})
await waitFor(() => {
// Step 3: loginKey
expect(api.loginKey).toHaveBeenCalledWith('newuser@example.com', mockSecretKey)
})
await waitFor(() => {
// Step 4: prepareCodeRegistration
expect(api.prepareCodeRegistration).toHaveBeenCalled()
})
await waitFor(() => {
// Step 5: navigate to /signup-keypad
expect(mockNavigate).toHaveBeenCalledWith('/signup-keypad', {
state: {
email: 'newuser@example.com',
secretKeyHex: mockSecretKey,
userId: 'user-xyz',
icons: mockIcons.icons,
},
})
})
})
})
describe('No method selection anywhere', () => {
it('login tab has no method selection step', () => {
renderSusiPage()
expect(screen.queryByText(/choose.*method/i)).not.toBeInTheDocument()
expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
})
it('signup tab has no method selection step', async () => {
const user = userEvent.setup()
renderSusiPage()
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(screen.queryByText(/choose.*method/i)).not.toBeInTheDocument()
expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,194 @@
/**
* Keypad component tests — verify rendering, selection, backspace, submit, SVG rendering.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Keypad from '../components/Keypad'
// Simple SVG test data
const makeSvg = (id: number) =>
`<svg data-testid="icon-${id}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="red"/></svg>`
function makeSvgs(count: number): string[] {
return Array.from({ length: count }, (_, i) => makeSvg(i))
}
describe('Keypad', () => {
let onComplete: (selection: number[]) => void
beforeEach(() => {
onComplete = vi.fn() as unknown as (selection: number[]) => void
})
it('renders correct number of keys based on numbOfKeys prop', () => {
const numbOfKeys = 6
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
const keys = screen.getAllByRole('button', { name: /Key \d+/ })
expect(keys).toHaveLength(numbOfKeys)
})
it('each key has attrsPerKey icon cells', () => {
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// Each key button should contain attrsPerKey icon cells
const keys = screen.getAllByRole('button', { name: /Key \d+/ })
keys.forEach((key) => {
const cells = key.querySelectorAll('.keypad-icon-cell')
expect(cells).toHaveLength(attrsPerKey)
})
})
it('pressing a key adds to selection (dot appears)', async () => {
const user = userEvent.setup()
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// Initially shows placeholder
expect(screen.getByText(/tap icons to enter/i)).toBeInTheDocument()
// Click first key
await user.click(screen.getByRole('button', { name: 'Key 1' }))
// Placeholder gone, dot appears
expect(screen.queryByText(/tap icons to enter/i)).not.toBeInTheDocument()
expect(screen.getByText('•')).toBeInTheDocument()
// Click second key
await user.click(screen.getByRole('button', { name: 'Key 2' }))
expect(screen.getByText('••')).toBeInTheDocument()
})
it('backspace removes last selection', async () => {
const user = userEvent.setup()
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// Add two selections
await user.click(screen.getByRole('button', { name: 'Key 1' }))
await user.click(screen.getByRole('button', { name: 'Key 2' }))
expect(screen.getByText('••')).toBeInTheDocument()
// Click backspace
await user.click(screen.getByRole('button', { name: /delete last selection/i }))
expect(screen.getByText('•')).toBeInTheDocument()
// Click backspace again
await user.click(screen.getByRole('button', { name: /delete last selection/i }))
expect(screen.getByText(/tap icons to enter/i)).toBeInTheDocument()
})
it('submit calls onComplete with selection array', async () => {
const user = userEvent.setup()
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// Select keys 0, 2, 1
await user.click(screen.getByRole('button', { name: 'Key 1' }))
await user.click(screen.getByRole('button', { name: 'Key 3' }))
await user.click(screen.getByRole('button', { name: 'Key 2' }))
// Click submit
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(onComplete).toHaveBeenCalledOnce()
expect(onComplete).toHaveBeenCalledWith([0, 2, 1])
})
it('submit is disabled when no selection', () => {
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
const submitBtn = screen.getByRole('button', { name: /submit/i })
expect(submitBtn).toBeDisabled()
})
it('renders SVGs as-is (no color manipulation)', () => {
const numbOfKeys = 2
const attrsPerKey = 1
const svgs = [
'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ff0000"/></svg>',
'<svg viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" fill="blue"/></svg>',
]
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// SVGs should be rendered with original colors intact
const circles = document.querySelectorAll('circle')
expect(circles).toHaveLength(1)
expect(circles[0].getAttribute('fill')).toBe('#ff0000')
const rects = document.querySelectorAll('rect')
expect(rects).toHaveLength(1)
expect(rects[0].getAttribute('fill')).toBe('blue')
})
})

View File

@@ -0,0 +1,103 @@
/**
* SusiPage tests — verify login/signup tabs render correctly
* and that NO method selection UI exists.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import SusiPage from '../pages/SusiPage'
// Mock the api module so no WASM/OPAQUE calls are made
vi.mock('../services/api', () => ({
loginKey: vi.fn(),
prepareCodeLogin: vi.fn(),
generateSecretKey: vi.fn(),
registerKey: vi.fn(),
prepareCodeRegistration: vi.fn(),
}))
function renderSusiPage() {
return render(
<MemoryRouter>
<SusiPage />
</MemoryRouter>,
)
}
describe('SusiPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Login tab (default)', () => {
it('renders email input, secret key input, and Login button', () => {
renderSusiPage()
expect(screen.getByPlaceholderText('email')).toBeInTheDocument()
expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
// There are two "Login" buttons: tab + submit. Verify submit exists.
const loginButtons = screen.getAllByRole('button', { name: /login/i })
expect(loginButtons.length).toBeGreaterThanOrEqual(1)
const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')
expect(submitBtn).toBeDefined()
})
it('does NOT render any method selection UI', () => {
renderSusiPage()
expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/choose method/i)).not.toBeInTheDocument()
expect(screen.queryByText(/select.*method/i)).not.toBeInTheDocument()
})
})
describe('Signup tab', () => {
it('renders email input and Sign Up button (no secret key input)', async () => {
const user = userEvent.setup()
renderSusiPage()
// Switch to signup tab
await user.click(screen.getByRole('button', { name: /sign up/i }))
// Signup has only email input
expect(screen.getByPlaceholderText('email')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('secret key (32 hex chars)')).not.toBeInTheDocument()
// Submit button says "Sign Up" (tab + form button both say "Sign Up")
const signUpButtons = screen.getAllByRole('button', { name: /sign up/i })
const submitBtn = signUpButtons.find(b => b.getAttribute('type') === 'submit')
expect(submitBtn).toBeDefined()
})
it('does NOT render method selection or key-based/code-based options', async () => {
const user = userEvent.setup()
renderSusiPage()
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/choose method/i)).not.toBeInTheDocument()
})
})
describe('Tab switching', () => {
it('switches between Login and Sign Up tabs', async () => {
const user = userEvent.setup()
renderSusiPage()
// Initially on Login tab — secret key field present
expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
// Click Sign Up
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(screen.queryByPlaceholderText('secret key (32 hex chars)')).not.toBeInTheDocument()
// Click Login to switch back
await user.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
})
})
})

1
src/__tests__/setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@@ -1,153 +1,301 @@
import { useState, useCallback, useEffect } from 'react'; /**
* SVG Icon Keypad — matches Flutter keypad.dart + keypad_interface.dart.
* SVGs render as-is (no color application per DoDNKode branch).
*/
import { useState, useCallback, useEffect, useRef } from 'react'
interface KeypadProps { interface KeypadProps {
/** Number of digits expected */ svgs: string[]
length: number; attrsPerKey: number
/** Called when all digits entered */ numbOfKeys: number
onComplete: (digits: number[]) => void; onComplete: (selection: number[]) => void
/** Optional: called on each digit press */ loading?: boolean
onDigit?: (digit: number, current: number[]) => void;
/** Show the entered digits (vs dots) */
showDigits?: boolean;
/** Label text above the indicator */
label?: string;
/** Disable input */
disabled?: boolean;
/** Error message to show */
error?: string;
/** Reset trigger — increment to clear */
resetKey?: number;
} }
const KEYS = [ function keyAspectRatio(attrsPerKey: number): number {
[1, 2, 3], if (attrsPerKey <= 3) return 21 / 7
[4, 5, 6], if (attrsPerKey <= 6) return 21 / 15
[7, 8, 9], if (attrsPerKey <= 9) return 1
[null, 0, 'del'], if (attrsPerKey <= 12) return 21 / 27.5
] as const; if (attrsPerKey <= 15) return 21 / 34
return 21 / 40.5
}
export function Keypad({ export default function Keypad({
length, svgs,
attrsPerKey,
numbOfKeys,
onComplete, onComplete,
onDigit, loading = false,
showDigits = false,
label,
disabled = false,
error,
resetKey = 0,
}: KeypadProps) { }: KeypadProps) {
const [digits, setDigits] = useState<number[]>([]); const [selection, setSelection] = useState<number[]>([])
const [pressedKey, setPressedKey] = useState<number | null>(null)
const [columns, setColumns] = useState(2)
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Reset when resetKey changes // Reset selection when svgs change
const svgsRef = useRef(svgs)
useEffect(() => { useEffect(() => {
setDigits([]); if (svgsRef.current !== svgs) {
}, [resetKey]); svgsRef.current = svgs
setSelection([])
const handlePress = useCallback(
(key: number | 'del') => {
if (disabled) return;
if (key === 'del') {
setDigits((d) => d.slice(0, -1));
return;
} }
}, [svgs])
setDigits((prev) => { // Responsive: 2 cols portrait, 3 cols landscape
if (prev.length >= length) return prev;
const next = [...prev, key];
onDigit?.(key, next);
if (next.length === length) {
// Slight delay so the UI shows the last dot
setTimeout(() => onComplete(next), 150);
}
return next;
});
},
[length, onComplete, onDigit, disabled]
);
// Keyboard support
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const update = () => setColumns(window.innerWidth > window.innerHeight ? 3 : 2)
if (disabled) return; update()
const n = parseInt(e.key); window.addEventListener('resize', update)
if (!isNaN(n) && n >= 0 && n <= 9) { return () => window.removeEventListener('resize', update)
handlePress(n); }, [])
} else if (e.key === 'Backspace') {
handlePress('del'); useEffect(() => {
return () => {
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
} }
}; }, [])
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); const handlePress = useCallback((keyIndex: number) => {
}, [handlePress, disabled]); setSelection(prev => [...prev, keyIndex])
setPressedKey(keyIndex)
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
pressTimerRef.current = setTimeout(() => setPressedKey(null), 200)
}, [])
const handleBackspace = useCallback(() => {
setSelection(prev => prev.slice(0, -1))
}, [])
const handleSubmit = useCallback(() => {
if (selection.length > 0) {
onComplete(selection)
}
}, [selection, onComplete])
// Keyboard: Backspace to delete, Enter to submit
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace') {
e.preventDefault()
handleBackspace()
} else if (e.key === 'Enter' && selection.length > 0 && !loading) {
e.preventDefault()
handleSubmit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleBackspace, handleSubmit, selection.length, loading])
const aspect = keyAspectRatio(attrsPerKey)
const iconRows = Math.ceil(attrsPerKey / 3)
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="keypad-root">
{/* Label */} {/* Input display: [• dots + ⌫] [Submit] */}
{label && ( <div className="keypad-input-row">
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium"> <div className="keypad-input-box">
{label} <div className="keypad-dots-area">
</p> {selection.length === 0 ? (
)} <span className="keypad-placeholder">Tap icons to enter your nKode</span>
) : (
{/* Dot indicators */} <span className="keypad-dot-text">
<div className="flex gap-3"> {'•'.repeat(selection.length)}
{Array.from({ length }).map((_, i) => (
<div
key={i}
className={`w-3.5 h-3.5 rounded-full transition-all duration-200 ${
i < digits.length
? 'bg-indigo-500 scale-110'
: 'bg-slate-200 dark:bg-slate-700'
}`}
>
{showDigits && i < digits.length && (
<span className="flex items-center justify-center w-full h-full text-[10px] text-white font-bold">
{digits[i]}
</span> </span>
)} )}
</div> </div>
<button
type="button"
className="keypad-backspace-btn"
onClick={handleBackspace}
disabled={selection.length === 0 || loading}
aria-label="Delete last selection"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" />
<line x1="18" y1="9" x2="12" y2="15" />
<line x1="12" y1="9" x2="18" y2="15" />
</svg>
</button>
</div>
<button
type="button"
className="keypad-submit-btn"
onClick={handleSubmit}
disabled={selection.length === 0 || loading}
>
{loading ? '…' : 'Submit'}
</button>
</div>
{/* Key tile grid */}
<div
className="keypad-grid"
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
role="group"
aria-label="nKode keypad"
>
{Array.from({ length: numbOfKeys }).map((_, keyIndex) => {
const isActive = pressedKey === keyIndex
const startIdx = keyIndex * attrsPerKey
const keyIcons = svgs.slice(startIdx, startIdx + attrsPerKey)
return (
<button
key={keyIndex}
type="button"
disabled={loading}
onClick={() => handlePress(keyIndex)}
onTouchStart={() => setPressedKey(keyIndex)}
onTouchEnd={() => {
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
pressTimerRef.current = setTimeout(() => setPressedKey(null), 150)
}}
className={`keypad-key ${isActive ? 'keypad-key--active' : ''}`}
style={{ aspectRatio: `${aspect}` }}
aria-label={`Key ${keyIndex + 1}`}
>
<div
className="keypad-key-icons"
style={{ gridTemplateRows: `repeat(${iconRows}, 1fr)` }}
>
{keyIcons.map((svg, iconIdx) => (
<div
key={iconIdx}
className="keypad-icon-cell"
dangerouslySetInnerHTML={{ __html: svg }}
/>
))} ))}
</div> </div>
{/* Error */}
{error && (
<p className="text-sm text-red-500 font-medium animate-pulse">{error}</p>
)}
{/* Keypad grid */}
<div className="grid grid-cols-3 gap-4">
{KEYS.flat().map((key, i) => {
if (key === null) {
return <div key={i} />;
}
if (key === 'del') {
return (
<button
key={i}
onClick={() => handlePress('del')}
disabled={disabled || digits.length === 0}
className="keypad-btn text-lg !bg-transparent !border-transparent !shadow-none
text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300
disabled:opacity-30"
aria-label="Delete"
>
</button> </button>
); )
}
return (
<button
key={i}
onClick={() => handlePress(key)}
disabled={disabled || digits.length >= length}
className="keypad-btn disabled:opacity-30"
>
{key}
</button>
);
})} })}
</div> </div>
<style>{`
.keypad-root {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 10px;
}
@media (max-width: 600px) {
.keypad-root { max-width: 95vw; }
}
@media (orientation: landscape) and (min-width: 601px) {
.keypad-root { max-width: 60vw; }
}
.keypad-input-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.keypad-input-box {
display: flex;
align-items: center;
flex: 1;
min-height: 48px;
background: white;
border: 4px solid black;
border-radius: 12px;
padding: 0 8px;
overflow: hidden;
}
.keypad-dots-area {
flex: 1;
min-width: 0;
padding: 4px 0;
}
.keypad-placeholder {
color: #a1a1aa;
font-size: 0.8rem;
}
.keypad-dot-text {
font-size: 36px;
line-height: 1;
color: black;
letter-spacing: 2px;
word-break: break-all;
}
.keypad-backspace-btn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: #333;
padding: 4px;
display: flex;
align-items: center;
}
.keypad-backspace-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.keypad-submit-btn {
flex-shrink: 0;
padding: 12px 20px;
border-radius: 12px;
background: #6366f1;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.keypad-submit-btn:hover:not(:disabled) { background: #4f46e5; }
.keypad-submit-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.keypad-grid {
display: grid;
gap: 8px;
width: 100%;
}
.keypad-key {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: transparent;
border: 4px solid rgba(0,0,0,0.12);
border-radius: 12px;
cursor: pointer;
transition: border-width 0.05s, border-color 0.05s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
outline: none;
}
.keypad-key:disabled { opacity: 0.4; cursor: not-allowed; }
.keypad-key:active,
.keypad-key--active {
border-width: 20px;
border-color: #000080;
}
.keypad-key-icons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
width: 100%;
height: 100%;
}
.keypad-icon-cell {
display: flex;
align-items: center;
justify-content: center;
background: white;
overflow: hidden;
}
.keypad-icon-cell svg,
.keypad-icon-cell img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
`}</style>
</div> </div>
); )
} }

View File

@@ -1,70 +1,53 @@
import { Link, Outlet, useLocation } from 'react-router-dom'; /**
import { useAuth } from '@/hooks/useAuth'; * App shell layout.
import { useTheme } from '@/hooks/useTheme'; */
import { ROUTES } from '@/lib/types'; import { Outlet, Link } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
export function Layout() { export default function Layout() {
const { isAuthenticated, email, logout } = useAuth(); const { isAuthenticated, email, logout } = useAuth()
const { resolved, setTheme, theme } = useTheme();
const location = useLocation();
const isAuthPage =
location.pathname === ROUTES.LOGIN || location.pathname === ROUTES.SIGNUP;
return ( return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 transition-colors"> <div className="min-h-screen bg-zinc-950 text-white flex flex-col">
{/* Header */} {/* Header */}
<header className="sticky top-0 z-50 border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm"> <header className="border-b border-zinc-800 bg-zinc-900/80 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between"> <div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 font-bold text-lg"> <Link to={isAuthenticated ? '/home' : '/'} className="flex items-center gap-2">
<span className="text-indigo-500">n</span> <div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center font-bold text-sm">
<span className="text-slate-900 dark:text-white">Kode</span> nK
</div>
<span className="font-semibold text-lg tracking-tight">nKode</span>
</Link> </Link>
<div className="flex items-center gap-3"> <nav className="flex items-center gap-4 text-sm font-medium">
{/* Theme toggle */}
<button
onClick={() =>
setTheme(
theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'
)
}
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title={`Theme: ${theme}`}
>
{resolved === 'dark' ? '🌙' : '☀️'}
</button>
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline"> <span className="text-zinc-500 text-xs">{email}</span>
{email}
</span>
<button <button
onClick={logout} onClick={logout}
className="text-sm px-3 py-1.5 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" className="text-zinc-400 hover:text-red-400 transition-colors"
> >
Sign out Logout
</button> </button>
</> </>
) : ( ) : (
!isAuthPage && ( <Link to="/" className="text-zinc-400 hover:text-zinc-200">Login / Sign Up</Link>
<Link
to={ROUTES.LOGIN}
className="text-sm px-4 py-1.5 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors"
>
Sign in
</Link>
)
)} )}
</div> </nav>
</div> </div>
</header> </header>
{/* Content */} {/* Main content */}
<main className="max-w-5xl mx-auto px-4 py-8"> <main className="flex-1 flex flex-col">
<div className="max-w-5xl mx-auto w-full px-4 py-8 flex-1">
<Outlet /> <Outlet />
</main>
</div> </div>
); </main>
{/* Footer */}
<footer className="border-t border-zinc-800 py-4 text-center text-xs text-zinc-600">
© {new Date().getFullYear()} nKode Passwordless Authentication
</footer>
</div>
)
} }

View File

@@ -1,64 +1,51 @@
import { useState, useCallback, useEffect, createContext, useContext } from 'react'; /**
import type { AuthState, NKodeSession } from '@/lib/types'; * Auth context — stores email + secretKey for OPAQUE-based auth.
* No tokens needed; the WASM client handles session keys internally.
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { createElement } from 'react'
import type { AuthState } from '../types'
import { loadAuth, saveAuth, clearAuth } from '../services/auth'
import { resetClient } from '../services/api'
const SESSION_KEY = 'nkode_session'; interface AuthContextValue extends AuthState {
const EMAIL_KEY = 'nkode_email'; /** Call after successful registration + code setup to persist credentials. */
login: (email: string, secretKeyHex: string, userId: string) => void
function loadStoredSession(): AuthState { /** Clear persisted credentials. */
try { logout: () => void
const raw = localStorage.getItem(SESSION_KEY); /** True if we have stored credentials (email + secretKey). */
const email = localStorage.getItem(EMAIL_KEY); isAuthenticated: boolean
if (raw) {
const session: NKodeSession = JSON.parse(raw);
// Check expiry
if (new Date(session.expiresAt) > new Date()) {
return { isAuthenticated: true, session, email };
}
localStorage.removeItem(SESSION_KEY);
}
} catch {}
return { isAuthenticated: false, session: null, email: null };
} }
export interface AuthContextType extends AuthState { const AuthContext = createContext<AuthContextValue | null>(null)
login: (email: string, session: NKodeSession) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType | null>(null); export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>(loadAuth)
export function useAuth(): AuthContextType { const login = useCallback((email: string, secretKeyHex: string, userId: string) => {
const ctx = useContext(AuthContext); const next: AuthState = { email, secretKeyHex, userId }
if (!ctx) throw new Error('useAuth must be inside AuthProvider'); setState(next)
return ctx; saveAuth(next)
} }, [])
export function useAuthState() {
const [state, setState] = useState<AuthState>(loadStoredSession);
const login = useCallback((email: string, session: NKodeSession) => {
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
localStorage.setItem(EMAIL_KEY, email);
setState({ isAuthenticated: true, session, email });
}, []);
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem(SESSION_KEY); setState({ email: null, secretKeyHex: null, userId: null })
localStorage.removeItem(EMAIL_KEY); clearAuth()
setState({ isAuthenticated: false, session: null, email: null }); resetClient()
}, []); }, [])
// Auto-logout on expiry const value: AuthContextValue = {
useEffect(() => { ...state,
if (!state.session) return; login,
const ms = new Date(state.session.expiresAt).getTime() - Date.now(); logout,
if (ms <= 0) { isAuthenticated: !!state.email && !!state.secretKeyHex && !!state.userId,
logout();
return;
} }
const timer = setTimeout(logout, ms);
return () => clearTimeout(timer);
}, [state.session, logout]);
return { ...state, login, logout }; return createElement(AuthContext.Provider, { value }, children)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
} }

View File

@@ -1,34 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
type Theme = 'light' | 'dark' | 'system';
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
return (localStorage.getItem('nkode_theme') as Theme) || 'system';
});
const resolved = theme === 'system' ? getSystemTheme() : theme;
useEffect(() => {
document.documentElement.classList.toggle('dark', resolved === 'dark');
}, [resolved]);
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setThemeState('system'); // re-trigger
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem('nkode_theme', t);
setThemeState(t);
}, []);
return { theme, resolved, setTheme };
}

View File

@@ -1,47 +1 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--nkode-primary: #6366f1;
--nkode-primary-hover: #4f46e5;
--nkode-surface: #ffffff;
--nkode-surface-alt: #f8fafc;
--nkode-border: #e2e8f0;
--nkode-text: #0f172a;
--nkode-text-muted: #64748b;
}
.dark {
--nkode-primary: #818cf8;
--nkode-primary-hover: #6366f1;
--nkode-surface: #0f172a;
--nkode-surface-alt: #1e293b;
--nkode-border: #334155;
--nkode-text: #f1f5f9;
--nkode-text-muted: #94a3b8;
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--nkode-surface);
color: var(--nkode-text);
-webkit-font-smoothing: antialiased;
}
/* Keypad button styles */
.keypad-btn {
@apply w-16 h-16 rounded-full text-2xl font-semibold
transition-all duration-150 ease-out
active:scale-95 select-none
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
border border-slate-200 dark:border-slate-700
hover:bg-slate-50 dark:hover:bg-slate-700
shadow-sm;
}
.keypad-btn:active {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}

View File

@@ -1,134 +0,0 @@
/**
* nKode WASM client wrapper.
*
* This module lazily loads the WASM package and provides a typed API.
* In development, it falls back to a mock client until the WASM package is linked.
*/
import type { NKodeSession } from './types';
// We'll import the WASM module dynamically
let wasmModule: any = null;
let wasmClient: any = null;
const API_BASE = import.meta.env.VITE_NKODE_API_URL || 'http://localhost:3000';
/**
* Try to load the WASM module from /wasm/ directory.
* Uses dynamic import with vite-ignore to avoid bundling.
*/
async function tryLoadWasm(): Promise<any> {
try {
// Check if WASM files are available
const check = await fetch('/wasm/nkode_client_wasm_bg.wasm', { method: 'HEAD' });
if (!check.ok) return null;
// Dynamic import — vite-ignore prevents bundler from resolving
const wasmUrl = new URL('/wasm/nkode_client_wasm.js', window.location.origin).href;
const module = await import(/* @vite-ignore */ wasmUrl);
await module.default('/wasm/nkode_client_wasm_bg.wasm');
module.init();
return module;
} catch {
return null;
}
}
/**
* Initialize the WASM module. Call once at app startup.
*/
export async function initNKode(): Promise<void> {
try {
const wasm = await tryLoadWasm();
if (wasm) {
wasmModule = wasm;
wasmClient = new wasm.NKodeClient(API_BASE);
console.log('[nKode] WASM client initialized');
} else {
console.warn('[nKode] WASM not available, using mock client');
}
} catch (e) {
console.warn('[nKode] WASM init error, using mock client:', e);
}
}
/**
* Check if WASM client is loaded.
*/
export function isWasmReady(): boolean {
return wasmClient !== null;
}
/**
* Generate a random 16-byte secret key (hex string).
*/
export function generateSecretKey(): string {
if (wasmModule) {
return wasmModule.NKodeClient.generateSecretKey();
}
// Fallback: browser crypto
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Register with key-based OPAQUE flow.
*/
export async function registerKey(email: string, secretKeyHex: string): Promise<void> {
if (wasmClient) {
await wasmClient.registerKey(email, secretKeyHex);
return;
}
// Mock
console.log('[nKode mock] registerKey', email);
await new Promise((r) => setTimeout(r, 500));
}
/**
* Register with code-based OPAQUE flow.
*/
export async function registerCode(email: string, passcode: Uint8Array): Promise<void> {
if (wasmClient) {
await wasmClient.registerCode(email, passcode);
return;
}
console.log('[nKode mock] registerCode', email);
await new Promise((r) => setTimeout(r, 500));
}
/**
* Login with key-based OPAQUE flow.
*/
export async function loginKey(email: string, secretKeyHex: string): Promise<NKodeSession> {
if (wasmClient) {
return await wasmClient.loginKey(email, secretKeyHex);
}
console.log('[nKode mock] loginKey', email);
await new Promise((r) => setTimeout(r, 500));
return {
sessionId: 'mock-session',
userId: 'mock-user',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 3600000).toISOString(),
};
}
/**
* Login with code-based OPAQUE flow.
*/
export async function loginCode(email: string, passcode: Uint8Array): Promise<NKodeSession> {
if (wasmClient) {
return await wasmClient.loginCode(email, passcode);
}
console.log('[nKode mock] loginCode', email);
await new Promise((r) => setTimeout(r, 500));
return {
sessionId: 'mock-session',
userId: 'mock-user',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 3600000).toISOString(),
};
}

View File

@@ -1,26 +0,0 @@
/** Authentication flow type */
export type AuthFlow = 'key' | 'code';
/** User session from successful login */
export interface NKodeSession {
sessionId: string;
userId: string;
createdAt: string;
expiresAt: string;
}
/** App-level auth state */
export interface AuthState {
isAuthenticated: boolean;
session: NKodeSession | null;
email: string | null;
}
/** Route paths */
export const ROUTES = {
LOGIN: '/login',
SIGNUP: '/signup',
HOME: '/',
ADMIN: '/admin',
DEVELOPER: '/developer',
} as const;

View File

@@ -1,14 +1,20 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client'
import './index.css'; import { BrowserRouter } from 'react-router-dom'
import App from './App'; import './index.css'
import { initNKode } from '@/lib/nkode-client'; import App from './App'
import { initClient } from './services/api'
// Initialize WASM client (non-blocking) // Initialize WASM client, then render
initNKode(); initClient().then(() => {
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter>
<App /> <App />
</StrictMode> </BrowserRouter>
); </StrictMode>,
)
}).catch((err: unknown) => {
console.error('Failed to initialize WASM client:', err)
document.getElementById('root')!.textContent = 'Failed to load application. Please refresh.'
})

View File

@@ -1,44 +0,0 @@
import { useAuth } from '@/hooks/useAuth';
import { Navigate } from 'react-router-dom';
import { ROUTES } from '@/lib/types';
export function AdminPage() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to={ROUTES.LOGIN} replace />;
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Admin Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ label: 'Total Users', value: '—', icon: '👤' },
{ label: 'Active Sessions', value: '—', icon: '🔐' },
{ label: 'Registered Clients', value: '—', icon: '📱' },
].map((stat) => (
<div
key={stat.label}
className="p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
<div className="flex items-center gap-3">
<span className="text-2xl">{stat.icon}</span>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">{stat.label}</p>
<p className="text-xl font-bold text-slate-900 dark:text-white">{stat.value}</p>
</div>
</div>
</div>
))}
</div>
<div className="p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<p className="text-slate-500 dark:text-slate-400 text-center">
Admin features coming soon. This will display user management, session monitoring, and OIDC client configuration.
</p>
</div>
</div>
);
}

View File

@@ -1,57 +0,0 @@
import { useAuth } from '@/hooks/useAuth';
import { Navigate } from 'react-router-dom';
import { ROUTES } from '@/lib/types';
export function DeveloperPage() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to={ROUTES.LOGIN} replace />;
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Developer Dashboard</h1>
<div className="p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 space-y-4">
<h2 className="font-semibold text-slate-900 dark:text-white">OIDC Client Setup</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Register your application to use nKode as an identity provider. Configure redirect URIs, scopes, and authentication flows.
</p>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Application Name
</label>
<input
type="text"
placeholder="My App"
className="w-full px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-900 text-slate-900 dark:text-white
placeholder:text-slate-400 dark:placeholder:text-slate-500"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Redirect URI
</label>
<input
type="text"
placeholder="https://myapp.com/callback"
className="w-full px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-900 text-slate-900 dark:text-white
placeholder:text-slate-400 dark:placeholder:text-slate-500"
disabled
/>
</div>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 italic">
Client registration coming soon. This will generate client_id and client_secret for OAuth2/OIDC integration.
</p>
</div>
</div>
);
}

View File

@@ -1,85 +1,46 @@
import { useAuth } from '@/hooks/useAuth'; /**
import { Keypad } from '@/components/Keypad'; * Home page — shown after full authentication (key + code login).
import { useState } from 'react'; */
import { useAuth } from '../hooks/useAuth'
import { useNavigate } from 'react-router-dom'
export function HomePage() { export default function HomePage() {
const { isAuthenticated, session, email } = useAuth(); const { email, userId, logout } = useAuth()
const [practiceResult, setPracticeResult] = useState<string | null>(null); const navigate = useNavigate()
const [resetKey, setResetKey] = useState(0);
if (!isAuthenticated) { const handleLogout = () => {
return ( logout()
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center"> navigate('/', { replace: true })
<h1 className="text-4xl font-bold text-slate-900 dark:text-white mb-4">
<span className="text-indigo-500">n</span>Kode
</h1>
<p className="text-lg text-slate-500 dark:text-slate-400 max-w-md mb-2">
Passwordless authentication powered by OPAQUE.
</p>
<p className="text-sm text-slate-400 dark:text-slate-500 max-w-md">
Replace passwords with a memorized numeric code or a cryptographic key.
Zero-knowledge proof means the server never sees your secret.
</p>
</div>
);
} }
const handlePractice = (digits: number[]) => {
setPracticeResult(`✅ You entered: ${digits.join('')}`);
setTimeout(() => {
setPracticeResult(null);
setResetKey((k) => k + 1);
}, 2000);
};
return ( return (
<div className="space-y-8"> <div className="flex flex-col h-full">
{/* Welcome */} <div className="flex items-center justify-between mb-6">
<div className="text-center"> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"> <h1 className="text-2xl font-semibold">Welcome</h1>
Welcome back <p className="text-zinc-400 text-sm mt-1">{email}</p>
</h1> <p className="text-zinc-500 text-xs mt-0.5">User: {userId}</p>
<p className="text-slate-500 dark:text-slate-400 mt-1">{email}</p> </div>
<button
onClick={handleLogout}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-red-400 transition-colors"
>
Logout
</button>
</div> </div>
{/* Session info */} <div className="flex-1 rounded-lg border border-zinc-800 bg-zinc-900 p-6 min-h-[400px]">
<div className="max-w-md mx-auto p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700"> <div className="flex flex-col items-center justify-center h-full text-zinc-500">
<h2 className="font-semibold text-slate-900 dark:text-white mb-3">Session</h2> <div className="w-16 h-16 rounded-2xl bg-emerald-600/20 flex items-center justify-center mb-4">
<dl className="space-y-2 text-sm"> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-400">
<div className="flex justify-between"> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<dt className="text-slate-500 dark:text-slate-400">Session ID</dt> <polyline points="22,4 12,14.01 9,11.01" />
<dd className="text-slate-900 dark:text-slate-100 font-mono text-xs truncate max-w-[200px]"> </svg>
{session?.sessionId}
</dd>
</div> </div>
<div className="flex justify-between"> <p className="text-lg text-emerald-400 font-medium">Authenticated via OPAQUE</p>
<dt className="text-slate-500 dark:text-slate-400">Expires</dt> <p className="text-sm text-zinc-600 mt-2">Key + Code authentication complete</p>
<dd className="text-slate-900 dark:text-slate-100">
{session?.expiresAt
? new Date(session.expiresAt).toLocaleString()
: '—'}
</dd>
</div>
</dl>
</div>
{/* Practice keypad */}
<div className="max-w-md mx-auto p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<h2 className="font-semibold text-slate-900 dark:text-white mb-4 text-center">
Practice your nKode
</h2>
<Keypad
length={6}
onComplete={handlePractice}
label="Enter any 6 digits"
resetKey={resetKey}
/>
{practiceResult && (
<p className="text-center mt-4 text-sm text-green-600 dark:text-green-400 font-medium">
{practiceResult}
</p>
)}
</div> </div>
</div> </div>
); </div>
)
} }

View File

@@ -0,0 +1,114 @@
/**
* Login keypad page — code-based OPAQUE login.
* Displays keypad from prepareCodeLogin data, user taps their nKode,
* then deciphers selection and performs code login.
*/
import { useState } from 'react'
import { useLocation, useNavigate, Navigate } from 'react-router-dom'
import Keypad from '../components/Keypad'
import { useAuth } from '../hooks/useAuth'
import * as api from '../services/api'
import type { CodeLoginData, NKodeIcon } from '../types'
interface LocationState {
email: string
secretKeyHex: string
userId: string
codeLoginData: CodeLoginData
}
export default function LoginKeypadPage() {
const location = useLocation()
const navigate = useNavigate()
const auth = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const state = location.state as LocationState | null
if (!state?.email || !state?.codeLoginData) {
return <Navigate to="/" replace />
}
const { email, secretKeyHex, userId, codeLoginData } = state
// Build SVG strings from icons for the keypad.
// Icons are already ordered by keypadIndices from prepareCodeLogin.
const svgs = buildSvgsFromCodeLoginData(codeLoginData)
const handleLogin = async (pressedKeys: number[]) => {
setLoading(true)
setError(null)
try {
// Decipher key selections into passcode bytes
const passcodeBytes = api.decipherSelection(
secretKeyHex,
codeLoginData.loginDataJson,
pressedKeys,
)
// OPAQUE code login
await api.loginCode(email, passcodeBytes)
// Save credentials and navigate home
auth.login(email, secretKeyHex, userId)
navigate('/home', { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-[600px] px-4 pt-4">
<div className="flex items-center gap-4 mb-4">
<button
onClick={() => navigate('/')}
className="text-zinc-400 hover:text-white transition-colors"
>
Back
</button>
<h1 className="text-2xl font-semibold text-white">Login</h1>
</div>
<p className="text-lg text-zinc-300 text-center mb-4">{email}</p>
</div>
{error && (
<div className="w-full max-w-[600px] px-4 mb-4">
<div className="bg-red-900/50 border border-red-700 rounded-lg p-3 text-red-300 text-sm">
{error}
</div>
</div>
)}
<Keypad
svgs={svgs}
attrsPerKey={codeLoginData.propertiesPerKey}
numbOfKeys={codeLoginData.numberOfKeys}
onComplete={handleLogin}
loading={loading}
/>
</div>
)
}
/** Build SVG strings array from CodeLoginData for the Keypad component. */
function buildSvgsFromCodeLoginData(data: CodeLoginData): string[] {
const { keypadIndices, icons } = data
// keypadIndices maps each position to an icon index
return keypadIndices.map((iconIdx: number) => iconToSvg(icons[iconIdx]))
}
/** Convert an icon object to an SVG/HTML string for rendering. */
function iconToSvg(icon: NKodeIcon): string {
if (!icon) return '<svg></svg>'
if (icon.file_type === 'svg') {
return icon.img_data
}
// For non-SVG: render as img with base64
const mime = icon.file_type === 'png' ? 'image/png'
: icon.file_type === 'webp' ? 'image/webp'
: 'image/jpeg'
return `<img src="data:${mime};base64,${icon.img_data}" alt="${icon.file_name}" />`
}

View File

@@ -1,124 +0,0 @@
import { useState, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Keypad } from '@/components/Keypad';
import { useAuth } from '@/hooks/useAuth';
import { loginCode } from '@/lib/nkode-client';
import { ROUTES } from '@/lib/types';
type Step = 'email' | 'keypad';
export function LoginPage() {
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [resetKey, setResetKey] = useState(0);
const { login } = useAuth();
const navigate = useNavigate();
const handleEmailSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
setError('');
setStep('keypad');
};
const handleKeypadComplete = useCallback(
async (digits: number[]) => {
setLoading(true);
setError('');
try {
const passcode = new Uint8Array(digits);
const session = await loginCode(email, passcode);
login(email, session);
navigate(ROUTES.HOME);
} catch (err: any) {
setError(err?.message || 'Authentication failed');
setResetKey((k) => k + 1);
} finally {
setLoading(false);
}
},
[email, login, navigate]
);
return (
<div className="flex flex-col items-center justify-center min-h-[70vh]">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
<span className="text-indigo-500">n</span>Kode
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-2">
{step === 'email' ? 'Sign in to your account' : 'Enter your nKode'}
</p>
</div>
{step === 'email' ? (
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
>
Email address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus
required
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-800 text-slate-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500
placeholder:text-slate-400 dark:placeholder:text-slate-500"
/>
</div>
<button
type="submit"
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
hover:bg-indigo-600 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
>
Continue
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Don't have an account?{' '}
<Link to={ROUTES.SIGNUP} className="text-indigo-500 hover:text-indigo-600 font-medium">
Sign up
</Link>
</p>
</form>
) : (
<div className="flex flex-col items-center">
<button
onClick={() => {
setStep('email');
setError('');
}}
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
{email}
</button>
<Keypad
length={6}
onComplete={handleKeypadComplete}
label="Enter your 6-digit nKode"
disabled={loading}
error={error}
resetKey={resetKey}
/>
{loading && (
<p className="mt-4 text-sm text-slate-500 animate-pulse">
Authenticating
</p>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Link } from 'react-router-dom'
export default function NotFoundPage() {
return (
<div className="flex flex-col items-center justify-center py-20">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-zinc-400 mb-6">Page not found</p>
<Link to="/" className="text-emerald-400 hover:text-emerald-300">
Back to Home
</Link>
</div>
)
}

View File

@@ -0,0 +1,165 @@
/**
* Signup keypad page — code registration flow.
* Displays icons for the user to select their nKode sequence.
* On submit, completes OPAQUE code registration and stores login data.
*/
import { useState, useCallback } from 'react'
import { useLocation, useNavigate, Navigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import * as api from '../services/api'
import type { NKodeIcon } from '../types'
interface LocationState {
email: string
secretKeyHex: string
userId: string
icons: NKodeIcon[]
}
/** Default keypad dimensions (matching Flutter: 9 props/key, 6 keys) */
const ATTRS_PER_KEY = 9
const NUM_KEYS = 6
export default function SignupKeypadPage() {
const location = useLocation()
const navigate = useNavigate()
const auth = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [secretKeyCopied, setSecretKeyCopied] = useState(false)
const state = location.state as LocationState | null
if (!state?.email || !state?.icons) {
return <Navigate to="/" replace />
}
const { email, secretKeyHex, userId, icons } = state
// Build SVG strings from icons for the keypad
const svgs = icons.map((icon: NKodeIcon) => iconToSvg(icon))
const handleCopyKey = useCallback(async () => {
try {
await navigator.clipboard.writeText(secretKeyHex)
setSecretKeyCopied(true)
setTimeout(() => setSecretKeyCopied(false), 3000)
} catch {
// Fallback: select text
setSecretKeyCopied(false)
}
}, [secretKeyHex])
const handleSetNKode = async (pressedKeys: number[]) => {
setLoading(true)
setError(null)
try {
// Convert key presses to global icon indices
// Each key press corresponds to keys on the keypad.
// The selectedIndices for completeCodeRegistration are the KEY indices pressed.
// The WASM SDK handles mapping keys to icon indices internally.
await api.completeCodeRegistrationWithEmail(email, pressedKeys)
// Save credentials and navigate home
auth.login(email, secretKeyHex, userId)
navigate('/home', { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-[600px] px-4 pt-4">
<div className="flex items-center gap-4 mb-4">
<button
onClick={() => navigate('/')}
className="text-zinc-400 hover:text-white transition-colors"
>
Back
</button>
<h1 className="text-2xl font-semibold text-white">Set Your nKode</h1>
</div>
<p className="text-lg text-zinc-300 text-center mb-2">{email}</p>
{/* Secret key display — user must save this */}
<div className="mb-4 p-4 bg-zinc-800 border border-zinc-600 rounded-lg">
<p className="text-sm text-zinc-400 mb-2">
Save your secret key you'll need it to log in:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-emerald-400 font-mono text-sm break-all select-all">
{secretKeyHex}
</code>
<button
type="button"
onClick={handleCopyKey}
className="px-3 py-1 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors flex-shrink-0"
>
{secretKeyCopied ? ' Copied' : 'Copy'}
</button>
</div>
</div>
<p className="text-sm text-zinc-500 text-center mb-4">
Tap keys to create your nKode pattern, then submit.
</p>
</div>
{error && (
<div className="w-full max-w-[600px] px-4 mb-4">
<div className="bg-red-900/50 border border-red-700 rounded-lg p-3 text-red-300 text-sm">
{error}
</div>
</div>
)}
<SignupKeypad
svgs={svgs}
attrsPerKey={ATTRS_PER_KEY}
numbOfKeys={NUM_KEYS}
onComplete={handleSetNKode}
loading={loading}
/>
</div>
)
}
/** Convert an icon object to an SVG/HTML string. */
function iconToSvg(icon: NKodeIcon): string {
if (!icon) return '<svg></svg>'
if (icon.file_type === 'svg') {
return icon.img_data
}
const mime = icon.file_type === 'png' ? 'image/png'
: icon.file_type === 'webp' ? 'image/webp'
: 'image/jpeg'
return `<img src="data:${mime};base64,${icon.img_data}" alt="${icon.file_name}" />`
}
/**
* Signup keypad — reuses the same keypad UI but allows selecting
* icons to define the nKode pattern.
*/
import Keypad from '../components/Keypad'
interface SignupKeypadProps {
svgs: string[]
attrsPerKey: number
numbOfKeys: number
onComplete: (selection: number[]) => void
loading: boolean
}
function SignupKeypad({ svgs, attrsPerKey, numbOfKeys, onComplete, loading }: SignupKeypadProps) {
return (
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
loading={loading}
/>
)
}

View File

@@ -1,277 +0,0 @@
import { useState, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Keypad } from '@/components/Keypad';
import { useAuth } from '@/hooks/useAuth';
import { registerCode, loginCode, generateSecretKey, registerKey } from '@/lib/nkode-client';
import { ROUTES } from '@/lib/types';
type Step = 'email' | 'method' | 'keypad' | 'confirm' | 'key-show' | 'done';
export function SignupPage() {
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [_method, setMethod] = useState<'code' | 'key'>('code');
const [firstCode, setFirstCode] = useState<number[]>([]);
const [secretKey, setSecretKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [resetKey, setResetKey] = useState(0);
const { login } = useAuth();
const navigate = useNavigate();
const handleEmailSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
setError('');
setStep('method');
};
const handleMethodSelect = (m: 'code' | 'key') => {
setMethod(m);
if (m === 'code') {
setStep('keypad');
} else {
// Generate key immediately
const key = generateSecretKey();
setSecretKey(key);
setStep('key-show');
}
};
const handleFirstCode = useCallback((digits: number[]) => {
setFirstCode(digits);
setResetKey((k) => k + 1);
setStep('confirm');
}, []);
const handleConfirmCode = useCallback(
async (digits: number[]) => {
// Check codes match
if (digits.join('') !== firstCode.join('')) {
setError("Codes don't match. Try again.");
setResetKey((k) => k + 1);
return;
}
setLoading(true);
setError('');
try {
const passcode = new Uint8Array(digits);
await registerCode(email, passcode);
// Auto-login after registration
const session = await loginCode(email, passcode);
login(email, session);
navigate(ROUTES.HOME);
} catch (err: any) {
setError(err?.message || 'Registration failed');
setResetKey((k) => k + 1);
} finally {
setLoading(false);
}
},
[email, firstCode, login, navigate]
);
const handleKeyRegister = async () => {
setLoading(true);
setError('');
try {
await registerKey(email, secretKey);
setStep('done');
} catch (err: any) {
setError(err?.message || 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh]">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
<span className="text-indigo-500">n</span>Kode
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-2">
{step === 'email' && 'Create your account'}
{step === 'method' && 'Choose your authentication method'}
{step === 'keypad' && 'Create your nKode'}
{step === 'confirm' && 'Confirm your nKode'}
{step === 'key-show' && 'Save your secret key'}
{step === 'done' && 'You\'re all set!'}
</p>
</div>
{step === 'email' && (
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
>
Email address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus
required
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-800 text-slate-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500
placeholder:text-slate-400 dark:placeholder:text-slate-500"
/>
</div>
<button
type="submit"
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
hover:bg-indigo-600 transition-colors"
>
Continue
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Already have an account?{' '}
<Link to={ROUTES.LOGIN} className="text-indigo-500 hover:text-indigo-600 font-medium">
Sign in
</Link>
</p>
</form>
)}
{step === 'method' && (
<div className="space-y-3">
<button
onClick={() => setStep('email')}
className="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 mb-2"
>
{email}
</button>
<button
onClick={() => handleMethodSelect('code')}
className="w-full p-4 rounded-xl border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-800 text-left hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors"
>
<div className="font-medium text-slate-900 dark:text-white">🔢 Code-based</div>
<div className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Enter a 6-digit code on a keypad. Easy to remember, quick to use.
</div>
</button>
<button
onClick={() => handleMethodSelect('key')}
className="w-full p-4 rounded-xl border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-800 text-left hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors"
>
<div className="font-medium text-slate-900 dark:text-white">🔑 Key-based</div>
<div className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Generate a cryptographic key. Maximum security, store it safely.
</div>
</button>
</div>
)}
{step === 'keypad' && (
<div className="flex flex-col items-center">
<button
onClick={() => setStep('method')}
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Back
</button>
<Keypad
length={6}
onComplete={handleFirstCode}
label="Choose your 6-digit nKode"
disabled={loading}
error={error}
resetKey={resetKey}
/>
</div>
)}
{step === 'confirm' && (
<div className="flex flex-col items-center">
<button
onClick={() => {
setStep('keypad');
setResetKey((k) => k + 1);
setError('');
}}
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Start over
</button>
<Keypad
length={6}
onComplete={handleConfirmCode}
label="Confirm your nKode"
disabled={loading}
error={error}
resetKey={resetKey}
/>
{loading && (
<p className="mt-4 text-sm text-slate-500 animate-pulse">
Creating account
</p>
)}
</div>
)}
{step === 'key-show' && (
<div className="space-y-4">
<button
onClick={() => setStep('method')}
className="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Back
</button>
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
Save this key you won't see it again!
</p>
<code className="block p-3 rounded-lg bg-white dark:bg-slate-800 text-sm font-mono break-all
text-slate-900 dark:text-slate-100 border border-slate-200 dark:border-slate-700">
{secretKey}
</code>
</div>
<button
onClick={() => navigator.clipboard.writeText(secretKey)}
className="w-full py-2 rounded-xl border border-slate-200 dark:border-slate-700
text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
📋 Copy to clipboard
</button>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
onClick={handleKeyRegister}
disabled={loading}
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
hover:bg-indigo-600 transition-colors disabled:opacity-50"
>
{loading ? 'Registering' : 'I\'ve saved my key — Register'}
</button>
</div>
)}
{step === 'done' && (
<div className="text-center space-y-4">
<div className="text-5xl"></div>
<p className="text-slate-600 dark:text-slate-300">
Your account has been created with key-based auth.
</p>
<Link
to={ROUTES.LOGIN}
className="inline-block px-6 py-2.5 rounded-xl bg-indigo-500 text-white font-medium
hover:bg-indigo-600 transition-colors"
>
Sign in
</Link>
</div>
)}
</div>
</div>
);
}

187
src/pages/SusiPage.tsx Normal file
View File

@@ -0,0 +1,187 @@
/**
* SUSI (Sign Up / Sign In) page.
* Login tab: email → OPAQUE key login → navigate to keypad for code login.
* Signup tab: email → OPAQUE key register → key login → navigate to keypad for code registration.
*/
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import * as api from '../services/api'
export default function SusiPage() {
const [tab, setTab] = useState<'login' | 'signup'>('login')
return (
<div className="flex flex-col items-center">
{/* Logo area */}
<div className="text-center mb-8">
<div className="w-24 h-24 rounded-2xl bg-emerald-600 flex items-center justify-center font-bold text-3xl mx-auto mb-4">
nK
</div>
<h1 className="text-3xl font-semibold">nKode</h1>
<p className="text-zinc-400 mt-1">Passwordless Authentication</p>
</div>
{/* Tab bar */}
<div className="flex border-b border-zinc-700 mb-6 w-full max-w-md">
<button
className={`flex-1 py-3 text-center font-medium transition-colors ${
tab === 'login'
? 'text-emerald-400 border-b-2 border-emerald-400'
: 'text-zinc-400 hover:text-zinc-200'
}`}
onClick={() => setTab('login')}
>
Login
</button>
<button
className={`flex-1 py-3 text-center font-medium transition-colors ${
tab === 'signup'
? 'text-emerald-400 border-b-2 border-emerald-400'
: 'text-zinc-400 hover:text-zinc-200'
}`}
onClick={() => setTab('signup')}
>
Sign Up
</button>
</div>
{/* Tab content */}
{tab === 'login' ? <LoginTab /> : <SignupTab />}
</div>
)
}
/** Login tab — email + secret key → OPAQUE key login → code login keypad */
function LoginTab() {
const [email, setEmail] = useState('')
const [secretKeyHex, setSecretKeyHex] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedEmail = email.trim()
const trimmedKey = secretKeyHex.trim()
if (!trimmedEmail) { setError('Enter an email'); return }
if (!trimmedKey || trimmedKey.length !== 32) {
setError('Enter your 32-character secret key (hex)')
return
}
setLoading(true)
setError(null)
try {
// Step 1: OPAQUE key login
const session = await api.loginKey(trimmedEmail, trimmedKey)
// Step 2: Prepare code login (fetch login data + reconstruct keypad)
const codeLoginData = await api.prepareCodeLogin(session.userId, trimmedKey)
// Navigate to keypad page for code login
navigate('/login-keypad', {
state: {
email: trimmedEmail,
secretKeyHex: trimmedKey,
userId: session.userId,
codeLoginData,
},
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="w-full max-w-md flex flex-col gap-5">
<input
type="email"
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-emerald-500"
/>
<input
type="password"
placeholder="secret key (32 hex chars)"
value={secretKeyHex}
onChange={(e) => setSecretKeyHex(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-emerald-500 font-mono"
/>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 font-medium transition-colors"
>
{loading ? 'Authenticating…' : 'Login'}
</button>
</form>
)
}
/** Sign Up tab — email → generate key → OPAQUE key register + key login → code registration keypad */
function SignupTab() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedEmail = email.trim()
if (!trimmedEmail) { setError('Enter an email'); return }
setLoading(true)
setError(null)
try {
// Step 1: Generate secret key
const secretKeyHex = api.generateSecretKey()
// Step 2: OPAQUE key registration
await api.registerKey(trimmedEmail, secretKeyHex)
// Step 3: OPAQUE key login (needed for authenticated endpoints)
const session = await api.loginKey(trimmedEmail, secretKeyHex)
// Step 4: Prepare code registration (fetch icons)
const iconsData = await api.prepareCodeRegistration()
// Navigate to keypad page for code registration
navigate('/signup-keypad', {
state: {
email: trimmedEmail,
secretKeyHex,
userId: session.userId,
icons: iconsData.icons,
},
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="w-full max-w-md flex flex-col gap-5">
<input
type="email"
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-emerald-500"
/>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 font-medium transition-colors"
>
{loading ? 'Setting up…' : 'Sign Up'}
</button>
</form>
)
}

112
src/services/api.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* nKode API service using WASM client SDK for OPAQUE crypto.
* All OPAQUE operations are performed client-side via WebAssembly.
*/
import { NKodeClient } from 'nkode-client-wasm'
import type { NKodeSession, IconsResponse, CodeLoginData } from '../types'
const API_BASE = import.meta.env.VITE_API_URL || ''
let client: NKodeClient | null = null
/** Initialize WASM module and create client. Must be called once before use. */
export async function initClient(): Promise<NKodeClient> {
// With vite-plugin-wasm + top-level-await, WASM is auto-initialized on import.
if (!client) {
client = new NKodeClient(API_BASE)
}
return client
}
/** Get or create a fresh client (call after initClient). */
export function getClient(): NKodeClient {
if (!client) throw new Error('Call initClient() first')
return client
}
/** Reset the client (e.g. on logout). */
export function resetClient(): void {
if (client) {
client.clearSession()
client.free()
}
client = null
}
/** Generate a new random secret key (hex string). */
export function generateSecretKey(): string {
return NKodeClient.generateSecretKey()
}
// ── Registration Flow ──
/** Register key-based auth via OPAQUE. */
export async function registerKey(email: string, secretKeyHex: string): Promise<void> {
const c = getClient()
await c.registerKey(email, secretKeyHex)
}
/** Login key-based auth via OPAQUE. Returns session info. */
export async function loginKey(email: string, secretKeyHex: string): Promise<NKodeSession> {
const c = getClient()
return await c.loginKey(email, secretKeyHex) as NKodeSession
}
/** Register code-based auth via OPAQUE. */
export async function registerCode(email: string, passcodeBytes: Uint8Array): Promise<void> {
const c = getClient()
await c.registerCode(email, passcodeBytes)
}
/** Login code-based auth via OPAQUE. Returns session info. */
export async function loginCode(email: string, passcodeBytes: Uint8Array): Promise<NKodeSession> {
const c = getClient()
return await c.loginCode(email, passcodeBytes) as NKodeSession
}
// ── Icon & Login Data Flows ──
/** Prepare icons for code registration (after key login). */
export async function prepareCodeRegistration(): Promise<IconsResponse> {
const c = getClient()
return await c.prepareCodeRegistration() as IconsResponse
}
/** Complete code registration with selected icon indices. */
export async function completeCodeRegistrationWithEmail(
email: string,
selectedIndices: number[],
): Promise<void> {
const c = getClient()
await c.completeCodeRegistrationWithEmail(email, new Uint32Array(selectedIndices))
}
/** Prepare code login: fetch login data, reconstruct keypad. */
export async function prepareCodeLogin(
userId: string,
secretKeyHex: string,
): Promise<CodeLoginData> {
const c = getClient()
return await c.prepareCodeLogin(userId, secretKeyHex) as CodeLoginData
}
/** Decipher key selections into passcode bytes for code login. */
export function decipherSelection(
secretKeyHex: string,
loginDataJson: string,
keySelections: number[],
): Uint8Array {
const c = getClient()
return c.decipherSelection(secretKeyHex, loginDataJson, new Uint32Array(keySelections))
}
/** Get user ID from current session. */
export function getUserId(): string | undefined {
const c = getClient()
return c.getUserId()
}
/** Check if client has an active session. */
export function hasSession(): boolean {
return client?.hasSession() ?? false
}

23
src/services/auth.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Auth state management — persists email + secretKey to localStorage.
* Uses OPAQUE-based auth via WASM client (no tokens stored).
*/
import type { AuthState } from '../types'
const STORAGE_KEY = 'nkode_auth'
export function loadAuth(): AuthState {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw) as AuthState
} catch { /* ignore corrupt data */ }
return { email: null, secretKeyHex: null, userId: null }
}
export function saveAuth(state: AuthState): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
export function clearAuth(): void {
localStorage.removeItem(STORAGE_KEY)
}

36
src/types/index.ts Normal file
View File

@@ -0,0 +1,36 @@
/** Auth state stored in localStorage */
export interface AuthState {
email: string | null
secretKeyHex: string | null
userId: string | null
}
/** Icon from the WASM client */
export interface NKodeIcon {
file_name: string
file_type: string
img_data: string
}
/** Response from prepareCodeRegistration */
export interface IconsResponse {
icons: NKodeIcon[]
}
/** Response from prepareCodeLogin */
export interface CodeLoginData {
keypadIndices: number[]
propertiesPerKey: number
numberOfKeys: number
mask: number[]
icons: NKodeIcon[]
loginDataJson: string
}
/** Session returned from loginKey / loginCode */
export interface NKodeSession {
sessionId: string
userId: string
createdAt: string
expiresAt: string
}

View File

@@ -1,19 +0,0 @@
/**
* Type stub for the nKode WASM package.
* Will be replaced by real types when the package is linked via `bun link`.
*/
declare module 'nkode-client-wasm' {
export class NKodeClient {
constructor(base_url: string);
loginCode(email: string, passcode_bytes: Uint8Array): Promise<any>;
loginKey(email: string, secret_key_hex: string): Promise<any>;
registerCode(email: string, passcode_bytes: Uint8Array): Promise<void>;
registerKey(email: string, secret_key_hex: string): Promise<void>;
static generateSecretKey(): string;
free(): void;
}
export function init(): void;
export default function __wbg_init(module_or_path?: any): Promise<any>;
}

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

26
src/wasm.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
declare module 'nkode-client-wasm' {
export class NKodeClient {
constructor(base_url: string)
static generateSecretKey(): string
registerKey(email: string, secret_key_hex: string): Promise<void>
loginKey(email: string, secret_key_hex: string): Promise<unknown>
registerCode(email: string, passcode_bytes: Uint8Array): Promise<void>
loginCode(email: string, passcode_bytes: Uint8Array): Promise<unknown>
hasSession(): boolean
getUserId(): string | undefined
clearSession(): void
free(): void
getNewIcons(count: number): Promise<unknown>
setIcons(icons_json: string): Promise<void>
getLoginData(user_id: string): Promise<unknown>
postLoginData(login_data_json: string): Promise<void>
updateLoginData(login_data_json: string): Promise<void>
prepareCodeRegistration(): Promise<unknown>
completeCodeRegistration(selected_indices: Uint32Array): Promise<void>
completeCodeRegistrationWithEmail(email: string, selected_indices: Uint32Array): Promise<void>
prepareCodeLogin(user_id: string, secret_key_hex: string): Promise<unknown>
decipherSelection(secret_key_hex: string, login_data_json: string, key_selections: Uint32Array): Uint8Array
}
export function init(): void
}

View File

@@ -8,7 +8,6 @@
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
@@ -16,17 +15,12 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -1,12 +1,36 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss(), wasm(), topLevelAwait()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
css: true,
},
resolve: { resolve: {
alias: { alias: {
'@': '/src', 'nkode-client-wasm': path.resolve(__dirname, 'pkg'),
}, },
}, },
server: {
proxy: {
'/v1': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
fs: {
allow: ['..'],
},
},
optimizeDeps: {
exclude: ['nkode-client-wasm'],
},
}) })