diff --git a/examples/showcase/README.md b/examples/showcase/README.md new file mode 100644 index 0000000..fffec6e --- /dev/null +++ b/examples/showcase/README.md @@ -0,0 +1,110 @@ +# React Auth Showcase + +A comprehensive example demonstrating **all key features** of [`@forward-software/react-auth`](../../lib/README.md). + +## Features Demonstrated + +| Feature | Where | +|---|---| +| `createAuth` setup | `src/auth.ts` | +| `AuthClient` implementation (full lifecycle) | `src/mock-auth-client.ts` | +| `AuthProvider` with `LoadingComponent` / `ErrorComponent` | `src/App.tsx` | +| `useAuthClient` hook | All components | +| `useSyncExternalStore` for reactive state | `Dashboard`, `AuthStatus`, `AuthenticatedView` | +| Token init / restore from `localStorage` | `MockAuthClient.onInit` | +| Login with credential validation | `LoginForm` | +| Token refresh | `AuthenticatedView` | +| Logout with storage cleanup | `AuthenticatedView` | +| Event system (`on` / `off`) | `EventLog` | +| All lifecycle hooks (`onPre*` / `onPost*`) | `MockAuthClient` | +| Error handling for failed login | `LoginForm` | +| `useAsyncCallback` with error tracking | `src/hooks/useAsyncCallback.ts` | +| `createMultiAuth` (multiple providers) | Not demonstrated — see [core library docs](../../lib/README.md) | + +## Prerequisites + +- [Node.js](https://nodejs.org/) ≥ 18 +- [pnpm](https://pnpm.io/) ≥ 10 + +## Setup + +From the **repository root**: + +```bash +pnpm install +``` + +## Local Development + +When developing inside the monorepo, `@forward-software/react-auth` imports resolve to the **local lib source** via build-tool aliases configured in `vite.config.ts`, `vitest.config.ts`, and `tsconfig.json`. No workspace linking is required. + +Users installing from npm get the published version specified in `package.json` (`^2.0.0`). + +## Running + +```bash +cd examples/showcase +pnpm dev +``` + +The app runs at [http://localhost:3003](http://localhost:3003). + +## Running Tests + +```bash +cd examples/showcase +pnpm test +``` + +## Valid Credentials + +| Field | Value | +|---|---| +| Username | `user` | +| Password | `password` | + +## Project Structure + +``` +src/ +├── mock-auth-client.ts # AuthClient implementation with full lifecycle +├── auth.ts # createAuth setup (exports AuthProvider, authClient, useAuthClient) +├── App.tsx # Root component with AuthProvider +├── main.tsx # Entry point +├── styles.css # Minimal styling +├── components/ +│ ├── Dashboard.tsx # Main layout with conditional rendering +│ ├── AuthStatus.tsx # Status badges + token display +│ ├── LoginForm.tsx # Login form with error handling +│ ├── AuthenticatedView.tsx # Refresh + logout actions +│ └── EventLog.tsx # Real-time auth event log +└── hooks/ + └── useAsyncCallback.ts # Reusable async action hook with error tracking + +test/ +├── test-utils.tsx # Test helpers and mock factory +├── mock-auth-client.spec.ts # Unit tests for MockAuthClient +└── components/ + ├── AuthStatus.spec.tsx + ├── LoginForm.spec.tsx + └── EventLog.spec.tsx +``` + +## UI Sections + +### Auth Status +Displays `isInitialized` and `isAuthenticated` as badges, and the current tokens as formatted JSON. Updates reactively via `useSyncExternalStore`. + +### Login Form +Username/password form with a **Login** button and a **Try Invalid Credentials** button that intentionally fails to demonstrate error handling. + +### Authenticated View +Shown after login. Displays token expiry time, a **Refresh Tokens** button, and a **Logout** button, each with loading states. + +### Event Log +Subscribes to all auth events (`initSuccess`, `loginStarted`, `loginFailed`, `refreshSuccess`, `logoutSuccess`, etc.) and displays them in a scrollable, timestamped list. Includes a **Clear** button. + +## Links + +- [Core Library README](../../lib/README.md) +- [Repository Root](../../README.md) diff --git a/examples/showcase/index.html b/examples/showcase/index.html new file mode 100644 index 0000000..6e9131b --- /dev/null +++ b/examples/showcase/index.html @@ -0,0 +1,14 @@ + + + + + + + React Auth Showcase + + + +
+ + + diff --git a/examples/showcase/package.json b/examples/showcase/package.json new file mode 100644 index 0000000..beeb5c8 --- /dev/null +++ b/examples/showcase/package.json @@ -0,0 +1,30 @@ +{ + "name": "showcase-example", + "version": "1.0.0", + "license": "MIT", + "private": true, + "scripts": { + "dev": "vite --host", + "build": "tsc && vite build", + "preview": "vite preview --host", + "test": "vitest run", + "test:watch": "vitest watch" + }, + "dependencies": { + "@forward-software/react-auth": "^2.0.0", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.0.1", + "typescript": "^6.0.2", + "vite": "^8.0.3", + "vitest": "^4.1.2" + } +} diff --git a/examples/showcase/pnpm-lock.yaml b/examples/showcase/pnpm-lock.yaml new file mode 100644 index 0000000..844bfad --- /dev/null +++ b/examples/showcase/pnpm-lock.yaml @@ -0,0 +1,1377 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@forward-software/react-auth': + specifier: ^2.0.0 + version: 2.1.0(react@19.2.4) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)) + jsdom: + specifier: ^29.0.1 + version: 29.0.1 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.3 + version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + vitest: + specifier: ^4.1.2 + version: 4.1.2(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)) + +packages: + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@5.1.4': + resolution: {integrity: sha512-503MoTEmPSyEJ7zQ+5vlkwPtkyxDhbDwR9ajk/jpPGrCLiUFHzgEG4iViUPKdGlZPRT1mWSPSbDL2qkOoLU4vg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.4': + resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@forward-software/react-auth@2.1.0': + resolution: {integrity: sha512-58y5an4LR+IDrZtjbujPBVngNKhEL3H4nXApLXzcojrad7YYWh3Jv+wxOj+JpkT5lmg3cjdh0jrX5GqhMG30EQ==} + peerDependencies: + react: '>=16.8' + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.0.1: + resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + engines: {node: '>=20.18.1'} + + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.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 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@5.1.4': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@7.0.4': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/runtime@7.29.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.0': {} + + '@forward-software/react-auth@2.1.0(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.122.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + entities@6.0.1: {} + + es-module-lexer@2.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + indent-string@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + jsdom@29.0.1: + dependencies: + '@asamuzakjp/css-color': 5.1.4 + '@asamuzakjp/dom-selector': 7.0.4 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.7 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lru-cache@11.2.7: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react@19.2.4: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + + rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: + optional: true + + typescript@6.0.2: {} + + undici@7.24.7: {} + + vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + vitest@4.1.2(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 29.0.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/examples/showcase/src/App.tsx b/examples/showcase/src/App.tsx new file mode 100644 index 0000000..b8febef --- /dev/null +++ b/examples/showcase/src/App.tsx @@ -0,0 +1,21 @@ +import { AuthProvider } from './auth'; +import { Dashboard } from './components/Dashboard'; + +const LoadingComponent = ( +
+
+

Initializing authentication…

+
+); + +const ErrorComponent = ( +
+

⚠️ Authentication failed to initialize. Please reload the page.

+
+); + +export const App = () => ( + + + +); diff --git a/examples/showcase/src/auth.ts b/examples/showcase/src/auth.ts new file mode 100644 index 0000000..7cb5a9c --- /dev/null +++ b/examples/showcase/src/auth.ts @@ -0,0 +1,4 @@ +import { createAuth } from '@forward-software/react-auth'; +import { MockAuthClient } from './mock-auth-client'; + +export const { AuthProvider, authClient, useAuthClient } = createAuth(new MockAuthClient()); diff --git a/examples/showcase/src/components/AuthStatus.tsx b/examples/showcase/src/components/AuthStatus.tsx new file mode 100644 index 0000000..7309d19 --- /dev/null +++ b/examples/showcase/src/components/AuthStatus.tsx @@ -0,0 +1,35 @@ +import { useSyncExternalStore } from 'react'; + +import { useAuthClient } from '../auth'; + +export const AuthStatus = () => { + const authClient = useAuthClient(); + const { isInitialized, isAuthenticated, tokens } = useSyncExternalStore( + authClient.subscribe, + authClient.getSnapshot, + ); + + return ( +
+

Auth Status

+
+ + Initialized: {String(isInitialized)} + + + Authenticated: {String(isAuthenticated)} + +
+
+

Current Tokens

+
{JSON.stringify(tokens, null, 2)}
+
+
+ ); +}; diff --git a/examples/showcase/src/components/AuthenticatedView.tsx b/examples/showcase/src/components/AuthenticatedView.tsx new file mode 100644 index 0000000..fd83431 --- /dev/null +++ b/examples/showcase/src/components/AuthenticatedView.tsx @@ -0,0 +1,46 @@ +import { useSyncExternalStore } from 'react'; + +import { useAuthClient } from '../auth'; +import { useAsyncCallback } from '../hooks/useAsyncCallback'; +import type { MockTokens } from '../mock-auth-client'; + +export const AuthenticatedView = () => { + const authClient = useAuthClient(); + const { tokens } = useSyncExternalStore( + authClient.subscribe, + authClient.getSnapshot, + ); + + const [doRefresh, isRefreshing] = useAsyncCallback( + () => authClient.refresh(), + [authClient], + ); + + const [doLogout, isLoggingOut] = useAsyncCallback( + () => authClient.logout(), + [authClient], + ); + + const typedTokens = tokens as Partial; + const expiryDate = typedTokens.expiresAt + ? new Date(typedTokens.expiresAt).toLocaleTimeString() + : 'N/A'; + + return ( +
+

Welcome!

+

You are authenticated.

+

+ Tokens expire at: {expiryDate} +

+
+ + +
+
+ ); +}; diff --git a/examples/showcase/src/components/Dashboard.tsx b/examples/showcase/src/components/Dashboard.tsx new file mode 100644 index 0000000..515ada5 --- /dev/null +++ b/examples/showcase/src/components/Dashboard.tsx @@ -0,0 +1,26 @@ +import { useSyncExternalStore } from 'react'; + +import { useAuthClient } from '../auth'; +import { AuthenticatedView } from './AuthenticatedView'; +import { AuthStatus } from './AuthStatus'; +import { EventLog } from './EventLog'; +import { LoginForm } from './LoginForm'; + +export const Dashboard = () => { + const authClient = useAuthClient(); + const { isAuthenticated } = useSyncExternalStore( + authClient.subscribe, + authClient.getSnapshot, + ); + + return ( +
+

React Auth Showcase

+ +
+ {isAuthenticated ? : } +
+ +
+ ); +}; diff --git a/examples/showcase/src/components/EventLog.tsx b/examples/showcase/src/components/EventLog.tsx new file mode 100644 index 0000000..654d406 --- /dev/null +++ b/examples/showcase/src/components/EventLog.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; + +import { authClient } from '../auth'; + +type AuthEvent = { + name: string; + timestamp: string; +}; + +const EVENT_NAMES = [ + 'initSuccess', + 'initFailed', + 'loginStarted', + 'loginSuccess', + 'loginFailed', + 'refreshStarted', + 'refreshSuccess', + 'refreshFailed', + 'logoutStarted', + 'logoutSuccess', + 'logoutFailed', +] as const; + +export const EventLog = () => { + const [events, setEvents] = useState([]); + + useEffect(() => { + const handlers = EVENT_NAMES.map((name) => { + const handler = () => { + setEvents((prev) => [ + ...prev, + { name, timestamp: new Date().toLocaleTimeString() }, + ]); + }; + authClient.on(name, handler); + return { name, handler }; + }); + + return () => { + handlers.forEach(({ name, handler }) => { + authClient.off(name, handler); + }); + }; + }, []); + + const clearEvents = () => setEvents([]); + + return ( +
+
+

Event Log

+ +
+
+ {events.length === 0 ? ( +

No events yet

+ ) : ( + events.map((event, index) => ( +
+ {event.timestamp} + {event.name} +
+ )) + )} +
+
+ ); +}; diff --git a/examples/showcase/src/components/LoginForm.tsx b/examples/showcase/src/components/LoginForm.tsx new file mode 100644 index 0000000..1755dda --- /dev/null +++ b/examples/showcase/src/components/LoginForm.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; + +import { useAuthClient } from '../auth'; +import { useAsyncCallback } from '../hooks/useAsyncCallback'; +import { VALID_CREDENTIALS } from '../mock-auth-client'; + +export const LoginForm = () => { + const authClient = useAuthClient(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const [doLogin, isLoading, loginError] = useAsyncCallback( + async () => { + const success = await authClient.login({ username, password }); + if (!success) { + throw new Error('Login failed. Please check your credentials.'); + } + }, + [authClient, username, password], + ); + + const [doInvalidLogin, isInvalidLoading, invalidError] = useAsyncCallback( + async () => { + const success = await authClient.login({ username: 'wrong', password: 'wrong' }); + if (!success) { + throw new Error('Login failed. Please check your credentials.'); + } + }, + [authClient], + ); + + const displayError = loginError ?? invalidError; + + return ( +
+

Login

+
+ + setUsername(e.target.value)} + placeholder="Enter username" + data-testid="username-input" + /> +
+
+ + setPassword(e.target.value)} + placeholder="Enter password" + data-testid="password-input" + /> +
+
+ + +
+ {displayError && ( +

+ {displayError.message} +

+ )} +

+ Valid credentials: {VALID_CREDENTIALS.username} /{' '} + {VALID_CREDENTIALS.password} +

+
+ ); +}; diff --git a/examples/showcase/src/hooks/useAsyncCallback.ts b/examples/showcase/src/hooks/useAsyncCallback.ts new file mode 100644 index 0000000..5a36b81 --- /dev/null +++ b/examples/showcase/src/hooks/useAsyncCallback.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react'; +import type { DependencyList } from 'react'; + +export function useAsyncCallback Promise>( + callback: T, + deps: DependencyList, +): [T, boolean, Error | null] { + const [isLoading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const wrappedCallback = useCallback(async (...args: never[]) => { + setLoading(true); + setError(null); + try { + const result = await callback(...args); + return result; + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); + } + }, deps) as T; + + return [wrappedCallback, isLoading, error]; +} diff --git a/examples/showcase/src/main.tsx b/examples/showcase/src/main.tsx new file mode 100644 index 0000000..9aab210 --- /dev/null +++ b/examples/showcase/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/showcase/src/mock-auth-client.ts b/examples/showcase/src/mock-auth-client.ts new file mode 100644 index 0000000..4dba5e2 --- /dev/null +++ b/examples/showcase/src/mock-auth-client.ts @@ -0,0 +1,110 @@ +import type { AuthClient } from '@forward-software/react-auth'; + +export type MockTokens = { + accessToken: string; + refreshToken: string; + expiresAt: number; +}; + +export type MockCredentials = { + username: string; + password: string; +}; + +export const VALID_CREDENTIALS: MockCredentials = { + username: 'user', + password: 'password', +}; + +const STORAGE_KEY = 'react-auth-showcase-tokens'; +const DEFAULT_DELAY_MS = 800; + +export class MockAuthClient implements AuthClient { + private delayMs: number; + + constructor(delayMs = DEFAULT_DELAY_MS) { + this.delayMs = delayMs; + } + + private wait(): Promise { + return new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + private generateTokens(): MockTokens { + return { + accessToken: `mock-access-${Date.now()}`, + refreshToken: `mock-refresh-${Date.now()}`, + expiresAt: Date.now() + 5 * 60 * 1000, + }; + } + + async onInit(): Promise { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + if ( + typeof parsed?.accessToken === 'string' && + typeof parsed?.refreshToken === 'string' && + typeof parsed?.expiresAt === 'number' && + parsed.expiresAt > Date.now() + ) { + return parsed as MockTokens; + } + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + return null; + } + + async onPostInit(): Promise { + // Post-initialization hook — no-op in this demo + } + + async onPreLogin(): Promise { + await this.wait(); + } + + async onLogin(credentials?: MockCredentials): Promise { + if ( + credentials?.username !== VALID_CREDENTIALS.username || + credentials?.password !== VALID_CREDENTIALS.password + ) { + throw new Error('Invalid credentials'); + } + const tokens = this.generateTokens(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens)); + return tokens; + } + + async onPostLogin(_isSuccess: boolean): Promise { + // Post-login hook — no-op in this demo + } + + async onPreRefresh(): Promise { + await this.wait(); + } + + async onRefresh(_currentTokens: MockTokens, _minValidity?: number): Promise { + const tokens = this.generateTokens(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens)); + return tokens; + } + + async onPostRefresh(_isSuccess: boolean): Promise { + // Post-refresh hook — no-op in this demo + } + + async onPreLogout(): Promise { + await this.wait(); + } + + async onLogout(): Promise { + localStorage.removeItem(STORAGE_KEY); + } + + async onPostLogout(_isSuccess: boolean): Promise { + // Post-logout hook — no-op in this demo + } +} diff --git a/examples/showcase/src/styles.css b/examples/showcase/src/styles.css new file mode 100644 index 0000000..3860be2 --- /dev/null +++ b/examples/showcase/src/styles.css @@ -0,0 +1,298 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, + Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + background: #f5f7fa; + color: #1a1a2e; + line-height: 1.6; +} + +h1 { + text-align: center; + margin-bottom: 1.5rem; + color: #16213e; +} + +h2 { + margin-top: 0; + margin-bottom: 0.75rem; + font-size: 1.2rem; + color: #16213e; +} + +h3 { + margin-top: 0.75rem; + margin-bottom: 0.5rem; + font-size: 1rem; + color: #333; +} + +/* Layout */ + +.dashboard { + max-width: 720px; + margin: 2rem auto; + padding: 0 1rem; +} + +.main-content { + margin-bottom: 1rem; +} + +/* Card */ + +.card { + background: #fff; + border-radius: 8px; + padding: 1.25rem; + margin-bottom: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +/* Badges */ + +.badges { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} + +.badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 500; +} + +.badge-success { + background: #d4edda; + color: #155724; +} + +.badge-pending { + background: #fff3cd; + color: #856404; +} + +.badge-inactive { + background: #e2e3e5; + color: #383d41; +} + +/* Token display */ + +.token-display pre { + background: #f0f2f5; + padding: 0.75rem; + border-radius: 6px; + overflow-x: auto; + font-size: 0.8rem; + line-height: 1.4; +} + +/* Form */ + +.form-group { + margin-bottom: 0.75rem; +} + +.form-group label { + display: block; + margin-bottom: 0.25rem; + font-weight: 500; + font-size: 0.9rem; +} + +.form-group input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 0.95rem; +} + +.form-group input:focus { + outline: none; + border-color: #4a90d9; + box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.2); +} + +/* Buttons */ + +.button-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.75rem; +} + +button { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + background: #4a90d9; + color: #fff; + transition: background 0.15s ease; +} + +button:hover:not(:disabled) { + background: #357abd; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Error */ + +.error-message { + color: #c0392b; + background: #fde8e8; + padding: 0.5rem 0.75rem; + border-radius: 6px; + margin-top: 0.75rem; + font-size: 0.9rem; +} + +/* Hint */ + +.hint { + margin-top: 0.75rem; + font-size: 0.85rem; + color: #666; +} + +.hint code { + background: #f0f2f5; + padding: 0.15rem 0.35rem; + border-radius: 4px; + font-size: 0.85rem; +} + +/* Expiry */ + +.expiry-info { + font-size: 0.9rem; + color: #555; +} + +/* Event Log */ + +.event-log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.event-log-header h2 { + margin-bottom: 0; +} + +.event-log-header button { + padding: 0.3rem 0.75rem; + font-size: 0.8rem; + background: #6c757d; +} + +.event-log-header button:hover:not(:disabled) { + background: #5a6268; +} + +.event-list { + max-height: 220px; + overflow-y: auto; + background: #f8f9fa; + border-radius: 6px; + padding: 0.5rem; + font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.8rem; +} + +.event-item { + display: flex; + gap: 0.75rem; + padding: 0.25rem 0.5rem; + border-bottom: 1px solid #e9ecef; +} + +.event-item:last-child { + border-bottom: none; +} + +.event-time { + color: #6c757d; + white-space: nowrap; +} + +.event-name { + font-weight: 500; + color: #333; +} + +.empty-message { + text-align: center; + color: #999; + padding: 1rem; + margin: 0; +} + +/* Loading / Error screens */ + +.loading-screen, +.error-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 50vh; + gap: 1rem; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid #e2e3e5; + border-top-color: #4a90d9; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive */ + +@media (max-width: 480px) { + .dashboard { + padding: 0 0.5rem; + } + + .badges { + flex-direction: column; + } + + .button-group { + flex-direction: column; + } + + button { + width: 100%; + } +} diff --git a/examples/showcase/src/vite-env.d.ts b/examples/showcase/src/vite-env.d.ts new file mode 100644 index 0000000..b81ec62 --- /dev/null +++ b/examples/showcase/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +declare module '*.css'; diff --git a/examples/showcase/test/components/AuthStatus.spec.tsx b/examples/showcase/test/components/AuthStatus.spec.tsx new file mode 100644 index 0000000..462e9a0 --- /dev/null +++ b/examples/showcase/test/components/AuthStatus.spec.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as rtl from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { TestAuthClient, flushPromises } from '../test-utils'; +import type { TestTokens } from '../test-utils'; + +// Create a shared test client accessible by both the mock factory and test bodies. +const testState = vi.hoisted(() => ({ + client: null as InstanceType | null, +})); + +vi.mock('../../src/auth', async () => { + const { createAuth } = await import('@forward-software/react-auth'); + const { TestAuthClient: Cls } = await import('../test-utils'); + testState.client = new Cls(); + return createAuth(testState.client); +}); + +// Imports resolved AFTER the mock is installed. +import { AuthStatus } from '../../src/components/AuthStatus'; +import { AuthProvider } from '../../src/auth'; + +afterEach(rtl.cleanup); + +describe('AuthStatus', () => { + it('should render initialization status', async () => { + // Arrange — default onInit returns null → initialized = true + + // Act + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert + expect(rtl.screen.getByTestId('initialized-badge')).toHaveTextContent( + 'Initialized: true', + ); + }); + + it('should render authentication status when not authenticated', async () => { + // Arrange — onInit returns null → not authenticated + + // Act + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert + expect(rtl.screen.getByTestId('authenticated-badge')).toHaveTextContent( + 'Authenticated: false', + ); + }); + + it('should render authentication status when authenticated', async () => { + // Arrange — onInit returns tokens → authenticated + const tokens: TestTokens = { + accessToken: 'init-access', + refreshToken: 'init-refresh', + expiresAt: Date.now() + 300_000, + }; + vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(tokens); + + // Act + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert + expect(rtl.screen.getByTestId('authenticated-badge')).toHaveTextContent( + 'Authenticated: true', + ); + }); + + it('should render token display', async () => { + // Arrange — onInit returns tokens + const tokens: TestTokens = { + accessToken: 'display-access', + refreshToken: 'display-refresh', + expiresAt: 1700000000000, + }; + vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(tokens); + + // Act + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert + const tokenDisplay = rtl.screen.getByTestId('token-display'); + expect(tokenDisplay).toHaveTextContent('display-access'); + expect(tokenDisplay).toHaveTextContent('display-refresh'); + }); +}); diff --git a/examples/showcase/test/components/AuthenticatedView.spec.tsx b/examples/showcase/test/components/AuthenticatedView.spec.tsx new file mode 100644 index 0000000..83ed5c5 --- /dev/null +++ b/examples/showcase/test/components/AuthenticatedView.spec.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as rtl from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { TestAuthClient, flushPromises } from '../test-utils'; +import type { TestTokens } from '../test-utils'; + +const testState = vi.hoisted(() => ({ + client: null as InstanceType | null, +})); + +vi.mock('../../src/auth', async () => { + const { createAuth } = await import('@forward-software/react-auth'); + const { TestAuthClient: Cls } = await import('../test-utils'); + testState.client = new Cls(); + return createAuth(testState.client); +}); + +import { AuthenticatedView } from '../../src/components/AuthenticatedView'; +import { AuthProvider } from '../../src/auth'; + +afterEach(rtl.cleanup); + +const MOCK_TOKENS: TestTokens = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Date.now() + 5 * 60 * 1000, +}; + +describe('AuthenticatedView', () => { + it('should render the authenticated view when logged in', async () => { + // Arrange — onInit returns tokens → authenticated + vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(MOCK_TOKENS); + + // Act + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert + expect(rtl.screen.getByTestId('authenticated-view')).toBeInTheDocument(); + expect(rtl.screen.getByText('You are authenticated.')).toBeInTheDocument(); + }); + + it('should show token expiry time', async () => { + // Arrange + const tokens: TestTokens = { + ...MOCK_TOKENS, + expiresAt: 1700000000000, + }; + vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(tokens); + + // Act + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert + const expiryEl = rtl.screen.getByTestId('token-expiry'); + expect(expiryEl).toBeInTheDocument(); + expect(expiryEl.textContent).toContain('Tokens expire at:'); + // The formatted time should not be N/A since we provided a valid expiresAt + expect(expiryEl.textContent).not.toContain('N/A'); + }); + + it('should trigger token refresh on refresh button click', async () => { + // Arrange + vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(MOCK_TOKENS); + const refreshSpy = vi.spyOn(testState.client!, 'onRefresh'); + + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Act + await rtl.act(async () => { + rtl.fireEvent.click(rtl.screen.getByTestId('refresh-button')); + await flushPromises(); + }); + + // Assert + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should trigger logout on logout button click', async () => { + // Arrange + vi.spyOn(testState.client!, 'onInit').mockResolvedValueOnce(MOCK_TOKENS); + const logoutSpy = vi.spyOn(testState.client!, 'onLogout'); + + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Act + await rtl.act(async () => { + rtl.fireEvent.click(rtl.screen.getByTestId('logout-button')); + await flushPromises(); + }); + + // Assert + expect(logoutSpy).toHaveBeenCalled(); + }); +}); diff --git a/examples/showcase/test/components/EventLog.spec.tsx b/examples/showcase/test/components/EventLog.spec.tsx new file mode 100644 index 0000000..cad2be6 --- /dev/null +++ b/examples/showcase/test/components/EventLog.spec.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as rtl from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { TestAuthClient, flushPromises } from '../test-utils'; + +const testState = vi.hoisted(() => ({ + client: null as InstanceType | null, +})); + +vi.mock('../../src/auth', async () => { + const { createAuth } = await import('@forward-software/react-auth'); + const { TestAuthClient: Cls } = await import('../test-utils'); + testState.client = new Cls(); + return createAuth(testState.client); +}); + +import { EventLog } from '../../src/components/EventLog'; +import { AuthProvider } from '../../src/auth'; + +afterEach(rtl.cleanup); + +describe('EventLog', () => { + it('should render event log container', async () => { + // Arrange & Act + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert + expect(rtl.screen.getByTestId('event-log')).toBeInTheDocument(); + expect(rtl.screen.getByTestId('event-list')).toBeInTheDocument(); + }); + + it('should display events when auth events fire', async () => { + // Arrange & Act — AuthProvider calls init(), which emits initSuccess + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert — initSuccess should appear from the provider's init call + const eventItems = rtl.screen.getAllByTestId('event-item'); + expect(eventItems.length).toBeGreaterThanOrEqual(1); + + const eventNames = eventItems.map((el) => el.textContent); + expect(eventNames.some((text) => text?.includes('initSuccess'))).toBe(true); + }); + + it('should clear events when clear button is clicked', async () => { + // Arrange — render and wait for init events + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Verify events exist first + expect(rtl.screen.getAllByTestId('event-item').length).toBeGreaterThanOrEqual(1); + + // Act — click the clear button + await rtl.act(async () => { + rtl.fireEvent.click(rtl.screen.getByTestId('clear-events-button')); + }); + + // Assert — no event items remain + expect(rtl.screen.queryAllByTestId('event-item')).toHaveLength(0); + expect(rtl.screen.getByText('No events yet')).toBeInTheDocument(); + }); +}); diff --git a/examples/showcase/test/components/LoginForm.spec.tsx b/examples/showcase/test/components/LoginForm.spec.tsx new file mode 100644 index 0000000..c6f22b5 --- /dev/null +++ b/examples/showcase/test/components/LoginForm.spec.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as rtl from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { TestAuthClient, flushPromises } from '../test-utils'; + +const testState = vi.hoisted(() => ({ + client: null as InstanceType | null, +})); + +vi.mock('../../src/auth', async () => { + const { createAuth } = await import('@forward-software/react-auth'); + const { TestAuthClient: Cls } = await import('../test-utils'); + testState.client = new Cls(); + return createAuth(testState.client); +}); + +import { LoginForm } from '../../src/components/LoginForm'; +import { AuthProvider } from '../../src/auth'; + +afterEach(rtl.cleanup); + +describe('LoginForm', () => { + it('should render login form inputs', async () => { + // Arrange & Act + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Assert + expect(rtl.screen.getByTestId('username-input')).toBeInTheDocument(); + expect(rtl.screen.getByTestId('password-input')).toBeInTheDocument(); + expect(rtl.screen.getByTestId('login-button')).toBeInTheDocument(); + }); + + it('should trigger auth login on button click', async () => { + // Arrange + const loginSpy = vi.spyOn(testState.client!, 'onLogin'); + + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Act — fill in credentials and click login + await rtl.act(async () => { + rtl.fireEvent.change(rtl.screen.getByTestId('username-input'), { + target: { value: 'user' }, + }); + rtl.fireEvent.change(rtl.screen.getByTestId('password-input'), { + target: { value: 'password' }, + }); + }); + + await rtl.act(async () => { + rtl.fireEvent.click(rtl.screen.getByTestId('login-button')); + await flushPromises(); + }); + + // Assert + expect(loginSpy).toHaveBeenCalled(); + }); + + it('should display error on failed login', async () => { + // Arrange — make onLogin reject + vi.spyOn(testState.client!, 'onLogin').mockRejectedValueOnce( + new Error('Bad credentials'), + ); + + rtl.render( + + + , + ); + await rtl.act(() => flushPromises()); + + // Act — click the invalid-login button (uses wrong credentials) + await rtl.act(async () => { + rtl.fireEvent.click(rtl.screen.getByTestId('invalid-login-button')); + await flushPromises(); + }); + + // Assert + expect(rtl.screen.getByTestId('login-error')).toBeInTheDocument(); + }); +}); diff --git a/examples/showcase/test/mock-auth-client.spec.ts b/examples/showcase/test/mock-auth-client.spec.ts new file mode 100644 index 0000000..9e5e9b8 --- /dev/null +++ b/examples/showcase/test/mock-auth-client.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { MockAuthClient, VALID_CREDENTIALS } from '../src/mock-auth-client'; +import type { MockTokens } from '../src/mock-auth-client'; + +const STORAGE_KEY = 'react-auth-showcase-tokens'; + +describe('MockAuthClient', () => { + let client: MockAuthClient; + + beforeEach(() => { + localStorage.clear(); + client = new MockAuthClient(0); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe('onInit', () => { + it('should return null when no tokens are stored', async () => { + // Arrange — empty localStorage + + // Act + const result = await client.onInit(); + + // Assert + expect(result).toBeNull(); + }); + + it('should restore valid tokens from localStorage', async () => { + // Arrange + const storedTokens: MockTokens = { + accessToken: 'stored-access', + refreshToken: 'stored-refresh', + expiresAt: Date.now() + 60_000, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(storedTokens)); + + // Act + const result = await client.onInit(); + + // Assert + expect(result).toEqual(storedTokens); + }); + + it('should return null for expired tokens', async () => { + // Arrange + const expiredTokens: MockTokens = { + accessToken: 'expired-access', + refreshToken: 'expired-refresh', + expiresAt: Date.now() - 1000, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(expiredTokens)); + + // Act + const result = await client.onInit(); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('onLogin', () => { + it('should return tokens for valid credentials', async () => { + // Arrange — use VALID_CREDENTIALS + + // Act + const tokens = await client.onLogin(VALID_CREDENTIALS); + + // Assert + expect(tokens).toHaveProperty('accessToken'); + expect(tokens).toHaveProperty('refreshToken'); + expect(tokens).toHaveProperty('expiresAt'); + expect(tokens.expiresAt).toBeGreaterThan(Date.now()); + }); + + it('should persist tokens to localStorage on success', async () => { + // Arrange — empty localStorage + + // Act + await client.onLogin(VALID_CREDENTIALS); + + // Assert + const stored = localStorage.getItem(STORAGE_KEY); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored!)).toHaveProperty('accessToken'); + }); + + it('should throw for invalid credentials', async () => { + // Arrange + const badCredentials = { username: 'wrong', password: 'wrong' }; + + // Act & Assert + await expect(client.onLogin(badCredentials)).rejects.toThrow('Invalid credentials'); + }); + + it('should throw when no credentials are provided', async () => { + // Act & Assert + await expect(client.onLogin()).rejects.toThrow('Invalid credentials'); + }); + }); + + describe('onRefresh', () => { + it('should generate new tokens', async () => { + // Arrange + const currentTokens: MockTokens = { + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: Date.now() + 10_000, + }; + + // Act + const newTokens = await client.onRefresh(currentTokens); + + // Assert + expect(newTokens.accessToken).not.toBe(currentTokens.accessToken); + expect(newTokens.refreshToken).not.toBe(currentTokens.refreshToken); + expect(newTokens.expiresAt).toBeGreaterThan(Date.now()); + }); + + it('should persist refreshed tokens to localStorage', async () => { + // Arrange + const currentTokens: MockTokens = { + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: Date.now() + 10_000, + }; + + // Act + const newTokens = await client.onRefresh(currentTokens); + + // Assert + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); + expect(stored.accessToken).toBe(newTokens.accessToken); + }); + }); + + describe('onLogout', () => { + it('should clear localStorage', async () => { + // Arrange + localStorage.setItem(STORAGE_KEY, '{"accessToken":"x"}'); + + // Act + await client.onLogout(); + + // Assert + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + }); + + describe('lifecycle hooks', () => { + it('should have callable onPostInit', async () => { + // Act & Assert + await expect(client.onPostInit()).resolves.toBeUndefined(); + }); + + it('should have callable onPreLogin', async () => { + // Act & Assert + await expect(client.onPreLogin()).resolves.toBeUndefined(); + }); + + it('should have callable onPostLogin', async () => { + // Act & Assert + await expect(client.onPostLogin(true)).resolves.toBeUndefined(); + await expect(client.onPostLogin(false)).resolves.toBeUndefined(); + }); + + it('should have callable onPreRefresh', async () => { + // Act & Assert + await expect(client.onPreRefresh()).resolves.toBeUndefined(); + }); + + it('should have callable onPostRefresh', async () => { + // Act & Assert + await expect(client.onPostRefresh(true)).resolves.toBeUndefined(); + await expect(client.onPostRefresh(false)).resolves.toBeUndefined(); + }); + + it('should have callable onPreLogout', async () => { + // Act & Assert + await expect(client.onPreLogout()).resolves.toBeUndefined(); + }); + + it('should have callable onPostLogout', async () => { + // Act & Assert + await expect(client.onPostLogout(true)).resolves.toBeUndefined(); + await expect(client.onPostLogout(false)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/examples/showcase/test/test-utils.tsx b/examples/showcase/test/test-utils.tsx new file mode 100644 index 0000000..77e9df6 --- /dev/null +++ b/examples/showcase/test/test-utils.tsx @@ -0,0 +1,55 @@ +/* istanbul ignore file */ + +import type { FC, PropsWithChildren } from 'react'; + +import { createAuth } from '@forward-software/react-auth'; +import type { AuthClient } from '@forward-software/react-auth'; + +export type TestTokens = { + accessToken: string; + refreshToken: string; + expiresAt: number; +}; + +export type TestCredentials = { + username: string; + password: string; +}; + +export class TestAuthClient implements AuthClient { + async onInit(): Promise { + return null; + } + + async onLogin(_credentials?: TestCredentials): Promise { + return { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Date.now() + 5 * 60 * 1000, + }; + } + + async onRefresh(_currentTokens: TestTokens): Promise { + return { + accessToken: 'refreshed-access-token', + refreshToken: 'refreshed-refresh-token', + expiresAt: Date.now() + 5 * 60 * 1000, + }; + } + + async onLogout(): Promise { + // no-op + } +} + +export function createTestAuth(client?: AuthClient) { + return createAuth(client ?? new TestAuthClient()); +} + +export function createTestWrapper(AuthProvider: FC) { + return function Wrapper({ children }: PropsWithChildren) { + return {children}; + }; +} + +export const flushPromises = () => new Promise(process.nextTick); diff --git a/examples/showcase/tsconfig.json b/examples/showcase/tsconfig.json new file mode 100644 index 0000000..60ec96e --- /dev/null +++ b/examples/showcase/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "paths": { + "@forward-software/react-auth": ["./../../lib/src/index.ts"] + } + }, + "include": ["src"] +} diff --git a/examples/showcase/vite.config.ts b/examples/showcase/vite.config.ts new file mode 100644 index 0000000..5081a90 --- /dev/null +++ b/examples/showcase/vite.config.ts @@ -0,0 +1,16 @@ +import path from 'node:path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@forward-software/react-auth': path.resolve(__dirname, '../../lib/src/index.ts'), + }, + }, + server: { + port: 3003, + open: true, + }, +}); diff --git a/examples/showcase/vitest.config.ts b/examples/showcase/vitest.config.ts new file mode 100644 index 0000000..69e146e --- /dev/null +++ b/examples/showcase/vitest.config.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@forward-software/react-auth': path.resolve(__dirname, '../../lib/src/index.ts'), + react: path.resolve(__dirname, 'node_modules/react'), + 'react-dom': path.resolve(__dirname, 'node_modules/react-dom'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + include: ['**/*.{test,spec}.{js,jsx,ts,tsx}'], + coverage: { + reporter: ['clover', 'lcov', 'html'], + include: ['src/**/*.{js,jsx,ts,tsx}'], + exclude: ['**/*.d.ts'], + }, + }, +});