diff --git a/Dockerfile b/Dockerfile index 3571d92..30a9b85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,18 @@ # Stage 1: Build FROM oven/bun:1 AS build WORKDIR /app -COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile -COPY . . -ARG VITE_NKODE_API_URL=https://api.nkode.donovankelly.xyz + +ARG 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 # Stage 2: Serve FROM nginx:alpine 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 EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/bun.lock b/bun.lock index b6056aa..42621c2 100644 --- a/bun.lock +++ b/bun.lock @@ -7,11 +7,17 @@ "dependencies": { "react": "^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": { "@eslint/js": "^9.39.1", "@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-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -19,15 +25,26 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "react-router-dom": "^7.13.0", + "jsdom": "^27.4.0", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", + "vitest": "^4.0.18", }, }, }, "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/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/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/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=="], + "@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/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=="], + "@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/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=="], + "@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=="], + "@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-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=="], + "@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/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=="], + "@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__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/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/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=="], @@ -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=="], + "@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-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=="], + "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=="], "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=="], "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=="], "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=="], + "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=="], "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=="], - "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=="], + "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=="], + "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=="], + "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=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "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=="], "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=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -348,8 +471,12 @@ "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=="], + "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-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=="], + "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=="], "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=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "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-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "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=="], + "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=="], "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=="], - "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=="], + "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=="], "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=="], + "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=="], "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=="], + "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-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=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -476,44 +627,76 @@ "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=="], "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-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-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=="], "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=="], "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-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=="], + "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=="], "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=="], "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=="], + "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=="], "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=="], + "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-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=="], + "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=="], + "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=="], "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=="], + "@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/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=="], + "@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/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=="], + "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=="], } } diff --git a/index.html b/index.html index 4db77b7..25e430c 100644 --- a/index.html +++ b/index.html @@ -2,11 +2,12 @@ + - nKode — Passwordless Auth - - - + + + + nKode
diff --git a/package.json b/package.json index e5bdc19..c64c65d 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,29 @@ { "name": "nkode-web", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "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": { "@eslint/js": "^9.39.1", "@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-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -24,10 +31,11 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "react-router-dom": "^7.13.0", + "jsdom": "^27.4.0", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.0.18" } } diff --git a/public/wasm/nkode_client_wasm_bg.js b/public/wasm/nkode_client_wasm_bg.js index 9416f3b..3c4a898 100644 --- a/public/wasm/nkode_client_wasm_bg.js +++ b/public/wasm/nkode_client_wasm_bg.js @@ -96,6 +96,14 @@ function getStringFromWasm0(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; function getUint8ArrayMemory0() { if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { @@ -145,6 +153,13 @@ function makeMutClosure(arg0, arg1, dtor, f) { 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) { const ptr = malloc(arg.length * 1, 1) >>> 0; getUint8ArrayMemory0().set(arg, ptr / 1); @@ -189,6 +204,12 @@ function passStringToWasm0(arg, malloc, realloc) { 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 }); cachedTextDecoder.decode(); const MAX_SAFARI_DECODE_BYTES = 2146435072; @@ -400,6 +421,56 @@ export class NKodeClient { const ret = wasm.nkodeclient_updateLoginData(this.__wbg_ptr, ptr0, len0); 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 - { keypadIndices, propertiesPerKey, numberOfKeys, mask, icons, loginDataJson } + * @param {string} user_id + * @param {string} secret_key_hex + * @returns {Promise} + */ + 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). * @returns {string} @@ -416,6 +487,52 @@ export class NKodeClient { 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 - JSON: { icons: [{ file_name, file_type, img_data }] } + * @returns {Promise} + */ + 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 + * @param {Uint32Array} selected_indices + * @returns {Promise} + */ + 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 + * @param {string} email + * @param {Uint32Array} selected_indices + * @returns {Promise} + */ + 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. * @param {string} base_url @@ -567,6 +684,10 @@ export function __wbg_fetch_8119fbf8d0e4f4d1(arg0, arg1) { 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) { arg0.getRandomValues(arg1); }, arguments) }; @@ -816,12 +937,6 @@ export function __wbg_versions_c01dfd4722a88165(arg0) { 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) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); @@ -852,6 +967,12 @@ export function __wbindgen_cast_d6cd19b81560fd6e(arg0) { 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() { const table = wasm.__wbindgen_externrefs; const offset = table.grow(4); diff --git a/public/wasm/nkode_client_wasm_bg.wasm b/public/wasm/nkode_client_wasm_bg.wasm index 4ac5f57..035991b 100644 Binary files a/public/wasm/nkode_client_wasm_bg.wasm and b/public/wasm/nkode_client_wasm_bg.wasm differ diff --git a/src/App.tsx b/src/App.tsx index 5935451..85cf8ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,41 +1,41 @@ -import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { Layout } from '@/components/Layout'; -import { AuthContext, useAuthState } from '@/hooks/useAuth'; -import { ROUTES } from '@/lib/types'; +import { Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider, useAuth } from './hooks/useAuth' +import Layout from './components/Layout' +import SusiPage from './pages/SusiPage' +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 }))); -const LoginPage = lazy(() => import('@/pages/LoginPage').then((m) => ({ default: m.LoginPage }))); -const SignupPage = lazy(() => import('@/pages/SignupPage').then((m) => ({ default: m.SignupPage }))); -const AdminPage = lazy(() => import('@/pages/AdminPage').then((m) => ({ default: m.AdminPage }))); -const DeveloperPage = lazy(() => import('@/pages/DeveloperPage').then((m) => ({ default: m.DeveloperPage }))); +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuth() + if (!isAuthenticated) return + return <>{children} +} -function Loading() { - return ( -
-
-
- ); +function GuestRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuth() + if (isAuthenticated) return + return <>{children} } export default function App() { - const auth = useAuthState(); - return ( - - - }> - - }> - } /> - } /> - } /> - } /> - } /> - - - - - - ); + + + }> + {/* Guest routes */} + } /> + } /> + } /> + + {/* Protected routes */} + } /> + + {/* 404 */} + } /> + + + + ) } diff --git a/src/__tests__/AuthFlow.test.tsx b/src/__tests__/AuthFlow.test.tsx new file mode 100644 index 0000000..729f8dd --- /dev/null +++ b/src/__tests__/AuthFlow.test.tsx @@ -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( + + + , + ) +} + +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: '' }, + ], + } + + 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() + }) + }) +}) diff --git a/src/__tests__/Keypad.test.tsx b/src/__tests__/Keypad.test.tsx new file mode 100644 index 0000000..e6953dc --- /dev/null +++ b/src/__tests__/Keypad.test.tsx @@ -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) => + `` + +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( + , + ) + + 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( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + 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 = [ + '', + '', + ] + + render( + , + ) + + // 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') + }) +}) diff --git a/src/__tests__/SusiPage.test.tsx b/src/__tests__/SusiPage.test.tsx new file mode 100644 index 0000000..aec39e3 --- /dev/null +++ b/src/__tests__/SusiPage.test.tsx @@ -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( + + + , + ) +} + +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() + }) + }) +}) diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/src/components/Keypad.tsx b/src/components/Keypad.tsx index 6177ea7..fc6309d 100644 --- a/src/components/Keypad.tsx +++ b/src/components/Keypad.tsx @@ -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 { - /** Number of digits expected */ - length: number; - /** Called when all digits entered */ - onComplete: (digits: number[]) => void; - /** Optional: called on each digit press */ - 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; + svgs: string[] + attrsPerKey: number + numbOfKeys: number + onComplete: (selection: number[]) => void + loading?: boolean } -const KEYS = [ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9], - [null, 0, 'del'], -] as const; +function keyAspectRatio(attrsPerKey: number): number { + if (attrsPerKey <= 3) return 21 / 7 + if (attrsPerKey <= 6) return 21 / 15 + if (attrsPerKey <= 9) return 1 + if (attrsPerKey <= 12) return 21 / 27.5 + if (attrsPerKey <= 15) return 21 / 34 + return 21 / 40.5 +} -export function Keypad({ - length, +export default function Keypad({ + svgs, + attrsPerKey, + numbOfKeys, onComplete, - onDigit, - showDigits = false, - label, - disabled = false, - error, - resetKey = 0, + loading = false, }: KeypadProps) { - const [digits, setDigits] = useState([]); + const [selection, setSelection] = useState([]) + const [pressedKey, setPressedKey] = useState(null) + const [columns, setColumns] = useState(2) + const pressTimerRef = useRef | null>(null) - // Reset when resetKey changes + // Reset selection when svgs change + const svgsRef = useRef(svgs) useEffect(() => { - setDigits([]); - }, [resetKey]); + if (svgsRef.current !== svgs) { + svgsRef.current = svgs + setSelection([]) + } + }, [svgs]) - const handlePress = useCallback( - (key: number | 'del') => { - if (disabled) return; - - if (key === 'del') { - setDigits((d) => d.slice(0, -1)); - return; - } - - setDigits((prev) => { - 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 + // Responsive: 2 cols portrait, 3 cols landscape useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (disabled) return; - const n = parseInt(e.key); - if (!isNaN(n) && n >= 0 && n <= 9) { - handlePress(n); - } else if (e.key === 'Backspace') { - handlePress('del'); + const update = () => setColumns(window.innerWidth > window.innerHeight ? 3 : 2) + update() + window.addEventListener('resize', update) + return () => window.removeEventListener('resize', update) + }, []) + + useEffect(() => { + return () => { + if (pressTimerRef.current) clearTimeout(pressTimerRef.current) + } + }, []) + + const handlePress = useCallback((keyIndex: number) => { + 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', handler); - return () => window.removeEventListener('keydown', handler); - }, [handlePress, disabled]); + } + 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 ( -
- {/* Label */} - {label && ( -

- {label} -

- )} - - {/* Dot indicators */} -
- {Array.from({ length }).map((_, i) => ( -
- {showDigits && i < digits.length && ( - - {digits[i]} +
+ {/* Input display: [• dots + ⌫] [Submit] */} +
+
+
+ {selection.length === 0 ? ( + Tap icons to enter your nKode + ) : ( + + {'•'.repeat(selection.length)} )}
- ))} + +
+
- {/* Error */} - {error && ( -

{error}

- )} + {/* Key tile grid */} +
+ {Array.from({ length: numbOfKeys }).map((_, keyIndex) => { + const isActive = pressedKey === keyIndex + const startIdx = keyIndex * attrsPerKey + const keyIcons = svgs.slice(startIdx, startIdx + attrsPerKey) - {/* Keypad grid */} -
- {KEYS.flat().map((key, i) => { - if (key === null) { - return
; - } - if (key === 'del') { - return ( - - ); - } return ( - ); + ) })}
+ +
- ); + ) } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3647310..89dad66 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,70 +1,53 @@ -import { Link, Outlet, useLocation } from 'react-router-dom'; -import { useAuth } from '@/hooks/useAuth'; -import { useTheme } from '@/hooks/useTheme'; -import { ROUTES } from '@/lib/types'; +/** + * App shell layout. + */ +import { Outlet, Link } from 'react-router-dom' +import { useAuth } from '../hooks/useAuth' -export function Layout() { - const { isAuthenticated, email, logout } = useAuth(); - const { resolved, setTheme, theme } = useTheme(); - const location = useLocation(); - - const isAuthPage = - location.pathname === ROUTES.LOGIN || location.pathname === ROUTES.SIGNUP; +export default function Layout() { + const { isAuthenticated, email, logout } = useAuth() return ( -
+
{/* Header */} -
+
- - n - Kode + +
+ nK +
+ nKode -
- {/* Theme toggle */} - - +
+
- {/* Content */} -
- + {/* Main content */} +
+
+ +
+ + {/* Footer */} +
+ © {new Date().getFullYear()} nKode — Passwordless Authentication +
- ); + ) } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index f571b26..2a7e002 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -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'; -const EMAIL_KEY = 'nkode_email'; - -function loadStoredSession(): AuthState { - try { - const raw = localStorage.getItem(SESSION_KEY); - const email = localStorage.getItem(EMAIL_KEY); - 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 }; +interface AuthContextValue extends AuthState { + /** Call after successful registration + code setup to persist credentials. */ + login: (email: string, secretKeyHex: string, userId: string) => void + /** Clear persisted credentials. */ + logout: () => void + /** True if we have stored credentials (email + secretKey). */ + isAuthenticated: boolean } -export interface AuthContextType extends AuthState { - login: (email: string, session: NKodeSession) => void; - logout: () => void; -} +const AuthContext = createContext(null) -export const AuthContext = createContext(null); +export function AuthProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState(loadAuth) -export function useAuth(): AuthContextType { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error('useAuth must be inside AuthProvider'); - return ctx; -} - -export function useAuthState() { - const [state, setState] = useState(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 login = useCallback((email: string, secretKeyHex: string, userId: string) => { + const next: AuthState = { email, secretKeyHex, userId } + setState(next) + saveAuth(next) + }, []) const logout = useCallback(() => { - localStorage.removeItem(SESSION_KEY); - localStorage.removeItem(EMAIL_KEY); - setState({ isAuthenticated: false, session: null, email: null }); - }, []); + setState({ email: null, secretKeyHex: null, userId: null }) + clearAuth() + resetClient() + }, []) - // Auto-logout on expiry - useEffect(() => { - if (!state.session) return; - const ms = new Date(state.session.expiresAt).getTime() - Date.now(); - if (ms <= 0) { - logout(); - return; - } - const timer = setTimeout(logout, ms); - return () => clearTimeout(timer); - }, [state.session, logout]); + const value: AuthContextValue = { + ...state, + login, + logout, + isAuthenticated: !!state.email && !!state.secretKeyHex && !!state.userId, + } - 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 } diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts deleted file mode 100644 index 054cefc..0000000 --- a/src/hooks/useTheme.ts +++ /dev/null @@ -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(() => { - 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 }; -} diff --git a/src/index.css b/src/index.css index 5b56308..f1d8c73 100644 --- a/src/index.css +++ b/src/index.css @@ -1,47 +1 @@ @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); -} diff --git a/src/lib/nkode-client.ts b/src/lib/nkode-client.ts deleted file mode 100644 index 7c26a33..0000000 --- a/src/lib/nkode-client.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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(), - }; -} diff --git a/src/lib/types.ts b/src/lib/types.ts deleted file mode 100644 index 8376dff..0000000 --- a/src/lib/types.ts +++ /dev/null @@ -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; diff --git a/src/main.tsx b/src/main.tsx index 98e77aa..4bd3d2a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,14 +1,20 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import './index.css'; -import App from './App'; -import { initNKode } from '@/lib/nkode-client'; +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import './index.css' +import App from './App' +import { initClient } from './services/api' -// Initialize WASM client (non-blocking) -initNKode(); - -createRoot(document.getElementById('root')!).render( - - - -); +// Initialize WASM client, then render +initClient().then(() => { + createRoot(document.getElementById('root')!).render( + + + + + , + ) +}).catch((err: unknown) => { + console.error('Failed to initialize WASM client:', err) + document.getElementById('root')!.textContent = 'Failed to load application. Please refresh.' +}) diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx deleted file mode 100644 index 0c1c98f..0000000 --- a/src/pages/AdminPage.tsx +++ /dev/null @@ -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 ; - } - - return ( -
-

Admin Dashboard

- -
- {[ - { label: 'Total Users', value: '—', icon: '👤' }, - { label: 'Active Sessions', value: '—', icon: '🔐' }, - { label: 'Registered Clients', value: '—', icon: '📱' }, - ].map((stat) => ( -
-
- {stat.icon} -
-

{stat.label}

-

{stat.value}

-
-
-
- ))} -
- -
-

- Admin features coming soon. This will display user management, session monitoring, and OIDC client configuration. -

-
-
- ); -} diff --git a/src/pages/DeveloperPage.tsx b/src/pages/DeveloperPage.tsx deleted file mode 100644 index 3205595..0000000 --- a/src/pages/DeveloperPage.tsx +++ /dev/null @@ -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 ; - } - - return ( -
-

Developer Dashboard

- -
-

OIDC Client Setup

-

- Register your application to use nKode as an identity provider. Configure redirect URIs, scopes, and authentication flows. -

- -
-
- - -
-
- - -
-
- -

- Client registration coming soon. This will generate client_id and client_secret for OAuth2/OIDC integration. -

-
-
- ); -} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index f9db0d3..d1b4dc4 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,85 +1,46 @@ -import { useAuth } from '@/hooks/useAuth'; -import { Keypad } from '@/components/Keypad'; -import { useState } from 'react'; +/** + * Home page — shown after full authentication (key + code login). + */ +import { useAuth } from '../hooks/useAuth' +import { useNavigate } from 'react-router-dom' -export function HomePage() { - const { isAuthenticated, session, email } = useAuth(); - const [practiceResult, setPracticeResult] = useState(null); - const [resetKey, setResetKey] = useState(0); +export default function HomePage() { + const { email, userId, logout } = useAuth() + const navigate = useNavigate() - if (!isAuthenticated) { - return ( -
-

- nKode -

-

- Passwordless authentication powered by OPAQUE. -

-

- Replace passwords with a memorized numeric code or a cryptographic key. - Zero-knowledge proof means the server never sees your secret. -

-
- ); + const handleLogout = () => { + logout() + navigate('/', { replace: true }) } - const handlePractice = (digits: number[]) => { - setPracticeResult(`✅ You entered: ${digits.join('')}`); - setTimeout(() => { - setPracticeResult(null); - setResetKey((k) => k + 1); - }, 2000); - }; - return ( -
- {/* Welcome */} -
-

- Welcome back -

-

{email}

+
+
+
+

Welcome

+

{email}

+

User: {userId}

+
+
- {/* Session info */} -
-

Session

-
-
-
Session ID
-
- {session?.sessionId} -
+
+
+
+ + + +
-
-
Expires
-
- {session?.expiresAt - ? new Date(session.expiresAt).toLocaleString() - : '—'} -
-
-
-
- - {/* Practice keypad */} -
-

- Practice your nKode -

- - {practiceResult && ( -

- {practiceResult} -

- )} +

Authenticated via OPAQUE

+

Key + Code authentication complete

+
- ); + ) } diff --git a/src/pages/LoginKeypadPage.tsx b/src/pages/LoginKeypadPage.tsx new file mode 100644 index 0000000..6df488f --- /dev/null +++ b/src/pages/LoginKeypadPage.tsx @@ -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(null) + + const state = location.state as LocationState | null + if (!state?.email || !state?.codeLoginData) { + return + } + + 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 ( +
+
+
+ +

Login

+
+

{email}

+
+ + {error && ( +
+
+ {error} +
+
+ )} + + +
+ ) +} + +/** 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 '' + 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 `${icon.file_name}` +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx deleted file mode 100644 index a396bd2..0000000 --- a/src/pages/LoginPage.tsx +++ /dev/null @@ -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('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 ( -
-
- {/* Logo */} -
-

- nKode -

-

- {step === 'email' ? 'Sign in to your account' : 'Enter your nKode'} -

-
- - {step === 'email' ? ( -
-
- - 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" - /> -
- -

- Don't have an account?{' '} - - Sign up - -

-
- ) : ( -
- - - {loading && ( -

- Authenticating… -

- )} -
- )} -
-
- ); -} diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..679162b --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,13 @@ +import { Link } from 'react-router-dom' + +export default function NotFoundPage() { + return ( +
+

404

+

Page not found

+ + ← Back to Home + +
+ ) +} diff --git a/src/pages/SignupKeypadPage.tsx b/src/pages/SignupKeypadPage.tsx new file mode 100644 index 0000000..bdfb796 --- /dev/null +++ b/src/pages/SignupKeypadPage.tsx @@ -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(null) + const [secretKeyCopied, setSecretKeyCopied] = useState(false) + + const state = location.state as LocationState | null + if (!state?.email || !state?.icons) { + return + } + + 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 ( +
+
+
+ +

Set Your nKode

+
+

{email}

+ + {/* Secret key display — user must save this */} +
+

+ Save your secret key — you'll need it to log in: +

+
+ + {secretKeyHex} + + +
+
+ +

+ Tap keys to create your nKode pattern, then submit. +

+
+ + {error && ( +
+
+ {error} +
+
+ )} + + +
+ ) +} + +/** Convert an icon object to an SVG/HTML string. */ +function iconToSvg(icon: NKodeIcon): string { + if (!icon) return '' + 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 `${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 ( + + ) +} diff --git a/src/pages/SignupPage.tsx b/src/pages/SignupPage.tsx deleted file mode 100644 index 7ef9ca3..0000000 --- a/src/pages/SignupPage.tsx +++ /dev/null @@ -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('email'); - const [email, setEmail] = useState(''); - const [_method, setMethod] = useState<'code' | 'key'>('code'); - const [firstCode, setFirstCode] = useState([]); - 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 ( -
-
- {/* Logo */} -
-

- nKode -

-

- {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!'} -

-
- - {step === 'email' && ( -
-
- - 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" - /> -
- -

- Already have an account?{' '} - - Sign in - -

-
- )} - - {step === 'method' && ( -
- - - -
- )} - - {step === 'keypad' && ( -
- - -
- )} - - {step === 'confirm' && ( -
- - - {loading && ( -

- Creating account… -

- )} -
- )} - - {step === 'key-show' && ( -
- -
-

- ⚠️ Save this key — you won't see it again! -

- - {secretKey} - -
- - {error &&

{error}

} - -
- )} - - {step === 'done' && ( -
-
-

- Your account has been created with key-based auth. -

- - Sign in - -
- )} -
-
- ); -} diff --git a/src/pages/SusiPage.tsx b/src/pages/SusiPage.tsx new file mode 100644 index 0000000..25828d5 --- /dev/null +++ b/src/pages/SusiPage.tsx @@ -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 ( +
+ {/* Logo area */} +
+
+ nK +
+

nKode

+

Passwordless Authentication

+
+ + {/* Tab bar */} +
+ + +
+ + {/* Tab content */} + {tab === 'login' ? : } +
+ ) +} + +/** 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(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 ( +
+ 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" + /> + 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 &&

{error}

} + +
+ ) +} + +/** 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(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 ( +
+ 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 &&

{error}

} + +
+ ) +} diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..b2b3ac8 --- /dev/null +++ b/src/services/api.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..cdc79f7 --- /dev/null +++ b/src/services/auth.ts @@ -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) +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..e36226d --- /dev/null +++ b/src/types/index.ts @@ -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 +} diff --git a/src/types/nkode-client-wasm.d.ts b/src/types/nkode-client-wasm.d.ts deleted file mode 100644 index cd20b93..0000000 --- a/src/types/nkode-client-wasm.d.ts +++ /dev/null @@ -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; - loginKey(email: string, secret_key_hex: string): Promise; - registerCode(email: string, passcode_bytes: Uint8Array): Promise; - registerKey(email: string, secret_key_hex: string): Promise; - static generateSecretKey(): string; - free(): void; - } - - export function init(): void; - - export default function __wbg_init(module_or_path?: any): Promise; -} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..8ab4f00 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/wasm.d.ts b/src/wasm.d.ts new file mode 100644 index 0000000..052cd36 --- /dev/null +++ b/src/wasm.d.ts @@ -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 + loginKey(email: string, secret_key_hex: string): Promise + registerCode(email: string, passcode_bytes: Uint8Array): Promise + loginCode(email: string, passcode_bytes: Uint8Array): Promise + hasSession(): boolean + getUserId(): string | undefined + clearSession(): void + free(): void + getNewIcons(count: number): Promise + setIcons(icons_json: string): Promise + getLoginData(user_id: string): Promise + postLoginData(login_data_json: string): Promise + updateLoginData(login_data_json: string): Promise + prepareCodeRegistration(): Promise + completeCodeRegistration(selected_indices: Uint32Array): Promise + completeCodeRegistrationWithEmail(email: string, selected_indices: Uint32Array): Promise + prepareCodeLogin(user_id: string, secret_key_hex: string): Promise + decipherSelection(secret_key_hex: string, login_data_json: string, key_selections: Uint32Array): Uint8Array + } + + export function init(): void +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 5db930a..c328724 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -8,7 +8,6 @@ "types": ["vite/client"], "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, @@ -16,17 +15,12 @@ "noEmit": true, "jsx": "react-jsx", - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } + "noUncheckedSideEffectImports": true }, "include": ["src"] } diff --git a/vite.config.ts b/vite.config.ts index 4d96565..6bed9d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,36 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' 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({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), wasm(), topLevelAwait()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/__tests__/setup.ts'], + css: true, + }, resolve: { 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'], + }, })