diff --git a/.gitattributes b/.gitattributes index bb6265e..fd6a149 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,18 @@ # Path-based git attributes # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html -# Ignore all test and documentation with "export-ignore". +# Left out of the Composer dist. docs/, llms.txt and resources/ ship: llms.txt and +# the Boost skill point at docs/, and javaabu/manifest serves resources/js. /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore +/.editorconfig export-ignore +/.github export-ignore /phpunit.xml.dist export-ignore -/.scrutinizer.yml export-ignore +/pint.json export-ignore /tests export-ignore -/.editorconfig export-ignore +/AGENTS.md export-ignore +/CLAUDE.md export-ignore +/package.json export-ignore +/pnpm-lock.yaml export-ignore +/tsconfig.json export-ignore +/vitest.config.ts export-ignore diff --git a/.github/coverage.svg b/.github/coverage.svg deleted file mode 100644 index cd166cb..0000000 --- a/.github/coverage.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - coverage - - 76 % - - \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 2481cb6..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: tests - -on: - pull_request: - branches: - - main - push: - branches: - - main - -jobs: - tests: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: true - matrix: - os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2 ] - laravel: [ 13.*, 12.*, 11.* ] - stability: [ prefer-lowest, prefer-stable ] - include: - - laravel: 13.* - testbench: 11.* - - laravel: 12.* - testbench: 10.* - - laravel: 11.* - testbench: ^9.16 - exclude: - - laravel: 13.* - php: 8.2 - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install SQLite 3 - run: | - sudo apt-get update - sudo apt-get install sqlite3 -y - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv - coverage: ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && 'xdebug' || 'none' }} - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: | - vendor/bin/phpunit ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && '--coverage-clover=clover.xml' || '' }} - - - name: Make code coverage badge - if: startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' - uses: timkrase/phpunit-coverage-badge@v1.2.1 - with: - coverage_badge_path: .github/coverage.svg - push_badge: true - repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6aabbda --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,178 @@ +name: tests + +on: + pull_request: + push: + branches: + - main + - next-gen + workflow_dispatch: + +# The unreleased javaabu/* dependencies come from VCS repositories; the job's own +# token keeps Composer within GitHub's API limits +env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}' + +jobs: + pint: + runs-on: ubuntu-latest + + name: Pint + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Check code style + run: vendor/bin/pint --test + + js: + runs-on: ubuntu-latest + + name: React pages (typecheck, tests) + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + # The pages import javaabu/auth's and javaabu/stencil's React code from vendor/ + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false + + # The default store is inside a node_modules folder, which breaks the declaration + # build of @javaabu/forms (installed from git and built on install): Javaabu/react-forms#20 + - name: Install dependencies + run: pnpm install --frozen-lockfile --store-dir "$RUNNER_TEMP/pnpm-store" + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + dependencies: + runs-on: ubuntu-latest + + name: No javaabu/helpers or javaabu/settings v1 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress --no-dev + + # javaabu/helpers pins javaabu/settings ^1, which breaks installs next to settings v2 + - name: Check the production dependency tree + run: | + composer show --tree --no-dev > tree.txt + if grep -E 'javaabu/helpers' tree.txt; then echo "javaabu/helpers is in the dependency tree"; exit 1; fi + if grep -E 'javaabu/settings +\^?1' tree.txt; then echo "javaabu/settings v1 is in the dependency tree"; exit 1; fi + if composer show --no-dev javaabu/settings 2>/dev/null | grep -E '^versions *: .*\b1\.'; then echo "javaabu/settings v1 is installed"; exit 1; fi + echo "OK" + + without-optional: + runs-on: ubuntu-latest + + name: Without the optional packages + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: curl, mbstring, zip, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies without Inertia, Sanctum and Passport + run: | + composer remove --dev inertiajs/inertia-laravel laravel/sanctum laravel/passport --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit --exclude-group optional + + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [ 8.4, 8.5 ] + laravel: [ 12.*, 13.* ] + stability: [ prefer-lowest, prefer-stable ] + include: + - laravel: 12.* + testbench: 10.* + - laravel: 13.* + testbench: 11.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} + + env: + COVERAGE: ${{ matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv + coverage: ${{ env.COVERAGE == 'true' && 'pcov' || 'none' }} + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + if: env.COVERAGE != 'true' + run: vendor/bin/phpunit + + - name: Execute tests with coverage + if: env.COVERAGE == 'true' + run: vendor/bin/phpunit --coverage-clover build/coverage.xml + + - name: Upload coverage to Codecov + if: env.COVERAGE == 'true' + uses: codecov/codecov-action@v5 + with: + files: build/coverage.xml + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 8683765..d37c91e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ composer.lock vendor coverage .phpunit.result.cache -.php-cs-fixer.cache .idea -clover.xml +node_modules +.phpunit.cache diff --git a/.php_cs.dist.php b/.php_cs.dist.php deleted file mode 100644 index 8648fdc..0000000 --- a/.php_cs.dist.php +++ /dev/null @@ -1,35 +0,0 @@ -in([ - __DIR__ . '/src', - __DIR__ . '/tests', - ]) - ->name('*.php') - ->notName('*.blade.php') - ->ignoreDotFiles(true) - ->ignoreVCS(true); - -return (new PhpCsFixer\Config()) - ->setRules([ - '@PSR12' => true, - 'array_syntax' => ['syntax' => 'short'], - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'trailing_comma_in_multiline' => true, - 'phpdoc_scalar' => true, - 'unary_operator_spaces' => true, - 'binary_operator_spaces' => true, - 'blank_line_before_statement' => [ - 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], - ], - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_var_without_name' => true, - 'method_argument_space' => [ - 'on_multiline' => 'ensure_fully_multiline', - 'keep_multiple_spaces_after_comma' => true, - ], - 'single_trait_insert_per_statement' => true, - ]) - ->setFinder($finder); diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5545ce0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# mobile-verification: notes for AI coding agents + +`javaabu/mobile-verification` v3 adds mobile number login, registration and verification with SMS codes to javaabu/auth v3 user types. It plugs in through javaabu/auth's `LoginMethod` and `StartAuthenticatedSession`, so treat those (and `UserTypes`, `PageData`, `ApiTokens`, `AccountLayout`) as the contract with auth; if auth lacks something, open a `cross-package` issue on n4ff4h/auth instead of working around it. + +## Layout + +| Path | What | +| --- | --- | +| `src/MobileVerification.php` | Static helpers: config, normalizing, finding numbers per user type, `enableFor()` | +| `src/Auth/MobileLoginMethod.php` | The javaabu/auth login method; registers the login and registration routes | +| `src/Routing` | `Route::mobileNumberRoutes()`, `Route::mobileApiRoutes()`, `Route::mobileNumberActions()` | +| `src/Http/Controllers` | `VerificationFlowController` (the shared send/verify/confirm/resend steps) and the login, registration and account flows; the API and admin actions controllers | +| `src/Actions` | `SendVerificationCode`, `VerifyVerificationCode`, `AuthenticateMobileNumber` (API/Passport), `CreateUserFromMobileNumber` | +| `src/Models/MobileNumber.php`, `src/Concerns/InteractsWithMobileNumbers.php` | The number (code, expiry, attempts, lock) and the user trait | +| `src/Support` | `PendingVerification` (session state per type and purpose), `Strings` (React strings), the code generator, the number format validator | +| `src/Passport/MobileGrant.php` | The optional `mobile` grant | +| `src/Policies/MobileNumberPolicy.php` | Admin abilities and permission names | +| `src/Stencil/MobileRegistrationForm.php` | The registration details form | +| `src/Views/ViewRenderer.php` | Blade (`mobile-verification::login`) or Inertia (`mobile-verification::Login`) | +| `resources/views`, `resources/js` | Blade pages (javaabu/forms v2) and React pages (`@javaabu/forms`, imports `@javaabu/auth` and `@javaabu/stencil` from vendor/) | +| `lang/en.json`, `lang/dv.json` | English keys and Dhivehi | +| `docs`, `llms.txt`, `resources/boost` | Docusaurus pages, the index for AI assistants, the Boost guideline and skill | +| `tests` | Testbench tests with two types: `user` (`web`, no route prefix, registration on) and `admin` (`web_admin`, `/admin`, registration off). Inertia, Sanctum and Passport tests are in the `optional` group. Seconds are frozen in `TestCase`. `tests/js` are Vitest tests | + +## Rules + +- Follow Javaabu codestyle ([boost-javaabu-guidelines](https://github.com/Javaabu/boost-javaabu-guidelines)): snake_case variables and properties, camelCase methods, early returns, `#[Test]` with snake_case names. Pint enforces formatting. +- Nothing may assume the `web` guard, `App\Models\User` or global route names. Resolve the type with `UserTypes::fromRequest()`, load users through `MobileVerification::userFor()` (the type's guard provider), and test both test types, including that one type's number doesn't log in another. +- Codes are security-sensitive: keep them hashed, single-use, expiring, counted and locked, and test the failure cases (wrong, expired, superseded, locked, rate limited). +- Inertia, Sanctum and Passport are optional: feature-detect them, never `use` their classes in code that always runs, keep CI's "Without the optional packages" job green. +- Every page exists in Blade and React with the same props. Logical utilities only (a test rejects `ml-`, `text-left`, …); numbers and codes are `dir="ltr"`. +- Every user-facing string goes through `__()` with an English key, a Dhivehi line in `lang/dv.json` and an English line in `lang/en.json` (`tests/Unit/TranslationsTest.php` checks). React strings go in `Support\Strings`; regenerate `tests/js/strings.dv.json` after changing them: + + ```bash + php -r 'require "vendor/autoload.php"; $dv = json_decode(file_get_contents("lang/dv.json"), true); $auth = json_decode(file_get_contents("vendor/javaabu/auth/lang/dv.json"), true); $out = []; foreach (Javaabu\MobileVerification\Support\Strings::ALL as $k => $en) { $out[$k] = $dv[$en] ?? $auth[$en] ?? $en; } foreach (["account" => "Account", "profile" => "Profile", "security" => "Security", "log_out" => "Log out"] as $k => $en) { $out[$k] = $auth[$en] ?? $en; } ksort($out); file_put_contents("tests/js/strings.dv.json", json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n");' + ``` + +- List uncertain Dhivehi in `docs/advanced-usage/translations.md`. Use Thaana in tests of user-facing behaviour. +- Never depend on `javaabu/helpers` or `javaabu/settings` ^1 (CI checks the production tree). +- Every change needs tests, docs (linked from `llms.txt`), the Boost skill if it changes how the package is used, and a line in `CHANGELOG.md`. +- Work on the `next-gen` branch; don't commit to `main`. + +## Commands + +```bash +composer install +composer lint # vendor/bin/pint --test +composer format # vendor/bin/pint +composer test # vendor/bin/phpunit +pnpm install # the React pages' dev tooling (after composer install) +pnpm check # typecheck and Vitest +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8869e77..8b265c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,18 @@ # Changelog -All notable changes will be documented in this file +All notable changes to `javaabu/mobile-verification` are documented in this file. -## 1.0.0 - 202X-XX-XX +## 3.0.0 - unreleased -- initial release +A rewrite on top of javaabu/auth v3. See `docs/upgrading.md`. + +- PHP 8.4+ and Laravel 12+; javaabu/auth v3, javaabu/forms v2, javaabu/stencil, javaabu/permissions v3 and javaabu/manifest. javaabu/helpers, javaabu/activitylog and javaabu/laravel-recaptcha dropped; Passport optional +- Mobile login as a javaabu/auth login method of any user type, finishing with `StartAuthenticatedSession` (two-factor still applies; `skip_two_factor`) +- Mobile registration with a javaabu/stencil details form and a `CreatesUsersFromMobileNumbers` action +- `Route::mobileNumberRoutes()` account page, linked from javaabu/auth's account navigation, and the `mobile.verified` middleware +- `Route::mobileApiRoutes()` for Sanctum/Passport tokens; the Passport `mobile` grant picks the user type from the client's provider +- `Route::mobileNumberActions()` admin actions behind `MobileNumberPolicy` and per-type javaabu/permissions permissions +- Blade and Inertia React pages; Dhivehi and RTL. The npm packages are declared for the pages only, so `manifest:check` doesn't ask Blade-only apps for React +- Codes from `random_int()`, configurable length, superseded by newer codes, named rate limiters, `request_code_rules` +- One `VerificationCodeNotification`, translated +- Docs, `llms.txt`, Laravel Boost guideline and skill diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/LICENSE b/LICENSE deleted file mode 100644 index e3e4a82..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Javaabu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index fd34e1a..f0fe941 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,69 @@ # Mobile Verification -[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/mobile-verification.svg?style=flat-square)](https://packagist.org/packages/javaabu/mobile-verification) -[![Test Status](../../actions/workflows/run-tests.yml/badge.svg)](../../actions/workflows/run-tests.yml) -![Code Coverage Badge](./.github/coverage.svg) -[![Total Downloads](https://img.shields.io/packagist/dt/javaabu/mobile-verification.svg?style=flat-square)](https://packagist.org/packages/javaabu/mobile-verification) +[![Tests](https://github.com/Javaabu/mobile-verification/actions/workflows/tests.yml/badge.svg?branch=next-gen)](https://github.com/Javaabu/mobile-verification/actions/workflows/tests.yml?query=branch%3Anext-gen) +[![codecov](https://codecov.io/gh/Javaabu/mobile-verification/branch/next-gen/graph/badge.svg)](https://codecov.io/gh/Javaabu/mobile-verification/tree/next-gen) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/mobile-verification.svg)](https://packagist.org/packages/javaabu/mobile-verification) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) -Adds mobile number verification to Laravel projects. +Log in, sign up and verify mobile numbers with one-time SMS codes in Laravel. Built on [javaabu/auth](https://github.com/Javaabu/auth): mobile login is a login method of any user type (admins, public users, …), two-factor authentication still applies, and the pages ship in Blade (javaabu/forms) and Inertia React (@javaabu/forms). Codes expire, wrong codes lock the number for a while, requests are rate limited, SMSs go through [javaabu/sms-notifications](https://github.com/Javaabu/sms-notifications), and everything is translated to Dhivehi. ## Documentation -You'll find the documentation on [https://docs.javaabu.com/mobile-verification](https://docs.javaabu.com/mobile-verification). +You'll find the documentation on [https://docs.javaabu.com/mobile-verification](https://docs.javaabu.com/mobile-verification), and in the [`docs`](docs) folder. Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible. If you've found a bug regarding security please mail [info@javaabu.com](mailto:info@javaabu.com) instead of using the issue tracker. -## Installation - -You can install the package via composer: +## Quick start ```bash composer require javaabu/mobile-verification +php artisan migrate ``` -Publishing Migrations -```bash -php artisan vendor:publish --provider="Javaabu\MobileVerification\MobileVerificationServiceProvider" --tag="mobile-verification-migrations" +```php +// config/javaabu-auth.php: add the login method to a user type +use Javaabu\MobileVerification\Auth\MobileLoginMethod; + +'user' => [ + 'guard' => 'web', + 'route_name' => '', + 'home' => '/dashboard', + 'login_methods' => [MobileLoginMethod::class], +], ``` -Publishing Config -```bash -php artisan vendor:publish --provider="Javaabu\MobileVerification\MobileVerificationServiceProvider" --tag="mobile-verification-config" +```php +// routes/web.php +Route::authRoutes('user'); // /login/mobile and /register/mobile +Route::mobileNumberRoutes('user'); // /settings/mobile-number + +Route::middleware(['auth.type:user', 'mobile.verified:user'])->group(function () { + Route::get('dashboard', DashboardController::class)->name('dashboard'); +}); ``` -Publishing Translations -```bash -php artisan vendor:publish --provider="Javaabu\MobileVerification\MobileVerificationServiceProvider" --tag="mobile-verification-translations" +```php +// app/Models/User.php +use Javaabu\Auth\Models\User as BaseUser; +use Javaabu\MobileVerification\Concerns\InteractsWithMobileNumbers; +use Javaabu\MobileVerification\Contracts\HasMobileNumber; + +class User extends BaseUser implements HasMobileNumber +{ + use InteractsWithMobileNumbers; +} ``` -## Testing +Upgrading from v2? See [`docs/upgrading.md`](docs/upgrading.md). For AI assistants, the package ships [`llms.txt`](llms.txt) and a Laravel Boost guideline and skill. -You can run the tests with +## Testing -``` bash -./vendor/bin/phpunit +```bash +composer test +composer lint +pnpm install && pnpm check # the React pages ``` ## Changelog diff --git a/Requirements.md b/Requirements.md deleted file mode 100644 index c4ef0f2..0000000 --- a/Requirements.md +++ /dev/null @@ -1,103 +0,0 @@ -# Requirements - -- Opt-in recaptcha - -## Verify Mobile Number Availability [DOCUMENTED] - -- Need to know user type - -### Workflow - -- [x] A guest user will enter their mobile number -- [x] The system will check if the mobile number is already registered -- [x] If the mobile number is already registered, the system will return a message that the mobile number is already - registered -- [x] If the mobile number is not registered, the system will return a message that the mobile number is available - -## Registration With Mobile Number [DOCUMENTED] - -- Should not be logged in -- Need to know user type - -### Workflow - -- [x] A guest user will enter their mobile number -- [x] The system will check if the mobile number is already registered -- [x] If the mobile number is already registered, the system will return a message that the mobile number is already - registered -- [x] If the mobile number is not registered, the system will send an OTP to the mobile number -- [x] The user will enter the OTP (and any other details required) -- [x] The system will verify the OTP (and the developer will verify the other details and register the user) - -## Mobile Number Login [DOCUMENTED] - -- Should not be logged in -- Need to know user type - -### Workflow - -- [x] A guest user should have an OTP -- [x] The user will enter the OTP -- [x] The system will verify the OTP and log in the user - -## Mobile Number Update [DOCUMENTED] - -- [x] User needs to be logged in to update mobile number -- [x] User can request for an OTP to be sent to the new mobile number -- [x] You cannot request for an OTP for an already existing number(with a user_id) -- [x] When you request for an OTP, a new record will be created but will not be associated with any user_id, so that - another user can take the number with verification - -### Workflow - -- [x] Request for OTP -- [x] Verify OTP, after verification the record will be associated with the user_id - -Sending OTP - -- [x] Send to any number -- [x] Send to a number that is not associated with any user_id -- [x] Send to a number that is associated with a user_id - -Verifying OTP - -- [x] Verify OTP for a number that is not associated with any user_id -- [x] Verify OTP for a number that is associated with a user_id -- [x] Verify OTP for any number - -- In controller -- - - one trait to send otp -- - - base trait, - -trait -SendsMobileVerificationCode; - -- sendOtp to any number - -LoginWithOtp -Add a grant type: (see: https://github.com/adaojunior/passport-social-grant) -MobileNumberGrant - -For api, -when a user sends their registration details with an otp, -provide a trait to register the user and return an access token -**Note:** Here the developer will control the end point and the controller, the given trait is implemented within the -controller - -UpdatesMobileNumber -SendMobileNumberForgotPasswordLink - -## Controllers - -- [x] Validating Mobile Number: ValidateMobileNumberControllerTest.php // This is for the mobile number availability - check -- [x] Registering with mobile number: RegisterControllerTest.php -- [x] Updating mobile number: MobileNumberUpdateControllerTest.php -- [x] Sending OTP: SendTokenControllerTest.php -- [x] Verifying OTP: TokenValidationControllerTest.php -- [x] Login In: TokenLoginControllerTest.php - -Verification Code -> OTP -Token -> Access Token API diff --git a/composer.json b/composer.json index adc7c1f..950dc93 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,79 @@ { "name": "javaabu/mobile-verification", - "description": "Adds mobile number verification to Laravel projects.", + "description": "Mobile number login, registration and verification with one-time SMS codes for Laravel, built on javaabu/auth: any number of user types, Blade and Inertia React pages, rate limiting and Dhivehi", "homepage": "https://github.com/Javaabu/mobile-verification", "license": "MIT", + "keywords": [ + "javaabu", + "laravel", + "mobile", + "otp", + "sms", + "verification", + "multi-auth" + ], "authors": [ { "name": "Arushad Ahmed", "email": "arushad@javaabu.com" } ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/n4ff4h/auth" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/forms" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/manifest" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/permissions" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/stencil" + } + ], "require": { - "php": "^8.2", - "illuminate/support": "^11.0 || ^12.0 || ^13.0", + "php": "^8.4", + "illuminate/auth": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/http": "^12.0 || ^13.0", + "illuminate/notifications": "^12.0 || ^13.0", + "illuminate/routing": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "illuminate/validation": "^12.0 || ^13.0", + "javaabu/auth": "^3.0@dev", + "javaabu/forms": "^2.0@dev", + "javaabu/manifest": "^1.0@dev", + "javaabu/permissions": "^3.0@dev", "javaabu/sms-notifications": "^1.3", - "javaabu/laravel-recaptcha": "^6.3", - "javaabu/helpers": "^1.71", - "javaabu/activitylog": "^1.6", - "laravel/passport": "^13.0" + "javaabu/stencil": "^1.0@dev" }, "require-dev": { - "orchestra/testbench": "^9.16 || ^10.0 || ^10.0 || ^11.0", - "phpunit/phpunit": "^10.5 || ^11.5.3 || ^12.5.8 || ^13.0.3", - "laravel/pint": "^1.15", - "javaabu/auth": "^2.0" + "inertiajs/inertia-laravel": "^2.0 || ^3.0", + "javaabu/boost-javaabu-guidelines": "^1.0", + "laravel/passport": "^13.0", + "laravel/pint": "^1.24", + "laravel/sanctum": "^4.0", + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.5.3 || ^12.5.8" }, "suggest": { - "javaabu/auth": "Required for login and registration" + "inertiajs/inertia-laravel": "Renders the pages with Inertia React when the user type or mobile-verification.views is inertia (^2.0 || ^3.0)", + "laravel/passport": "The `mobile` OAuth2 grant: an access token for a mobile number and code (^13.0)", + "laravel/sanctum": "Personal access tokens for a mobile number and code, from Route::mobileApiRoutes() (^4.0)" }, "autoload": { "psr-4": { - "Javaabu\\MobileVerification\\": "src/" + "Javaabu\\MobileVerification\\": "src/", + "Javaabu\\MobileVerification\\Database\\Factories\\": "database/factories/" } }, "autoload-dev": { @@ -37,9 +81,15 @@ "Javaabu\\MobileVerification\\Tests\\": "tests/" } }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-clover build/coverage.xml", + "lint": "vendor/bin/pint --test", + "format": "vendor/bin/pint" + }, "extra": { "branch-alias": { - "dev-main": "1.0-dev" + "dev-next-gen": "3.x-dev" }, "laravel": { "providers": [ @@ -47,6 +97,9 @@ ] } }, + "config": { + "sort-packages": true + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/mobile-verification.php b/config/mobile-verification.php index e0b16e5..956ef88 100644 --- a/config/mobile-verification.php +++ b/config/mobile-verification.php @@ -1,142 +1,205 @@ env('MOBILE_NUMBER_DB_CONNECTION'), + + 'views' => env('MOBILE_VERIFICATION_VIEWS'), /* |-------------------------------------------------------------------------- - | Mobile Numbers table name + | Mobile numbers table |-------------------------------------------------------------------------- - | This is the name of the table that will be created by the mobile numbers - | migration and used by the MobileNumber model shipped with this package. + | + | The connection and table of the mobile numbers. The connection defaults + | to database.default. The model must extend the package's model. + | */ + + 'database_connection' => env('MOBILE_NUMBER_DB_CONNECTION'), + 'table_name' => 'mobile_numbers', - /* - |-------------------------------------------------------------------------- - | Mobile Number Model - |-------------------------------------------------------------------------- - | This model will be used to save mobile numbers. - | It should implement the Javaabu\MobileVerification\Contracts\MobileNumber interface - | and extend Illuminate\Database\Eloquent\Model. - */ - 'mobile_number_model' => \Javaabu\MobileVerification\Models\MobileNumber::class, + 'mobile_number_model' => MobileNumber::class, /* |-------------------------------------------------------------------------- - | Default country code + | Country codes |-------------------------------------------------------------------------- - | The country code to be used by default + | + | The country code numbers get when none is given, the ones users can + | choose from, and what goes before the country code when a number is + | shown (+960 7771234). + | */ + 'default_country_code' => env('MOBILE_NUMBER_DEFAULT_COUNTRY_CODE', '960'), - /* - |-------------------------------------------------------------------------- - | Allowed country codes - |-------------------------------------------------------------------------- - | Which country codes are allowed - */ 'allowed_country_codes' => explode(',', env('MOBILE_NUMBER_ALLOWED_COUNTRY_CODES', '960')), + 'number_prefix' => '+', + /* - |-------------------------------------------------------------------------- - | Mobile grant allowed client providers - |-------------------------------------------------------------------------- - | List of user providers that can be used to be authenticated using mobile number grant type - * */ - 'mobile_grant_allowed_providers' => ['users'], + | Checks a number's format for its country code. The default accepts 7 + | digit Maldivian mobile numbers starting with 7 or 9, and any number of + | 4 to 15 digits for other country codes. Implements + | Javaabu\MobileVerification\Contracts\ValidatesNumberFormat. + */ + + 'number_format_validator' => NumberFormatValidator::class, /* |-------------------------------------------------------------------------- - | Number prefix + | Verification codes |-------------------------------------------------------------------------- - | The prefix to add before fully qualified numbers + | + | code_generator implements Contracts\GeneratesVerificationCodes + | code_length digits in a code + | verification_code_validity minutes a code stays valid + | max_attempts wrong codes before the number is locked + | attempt_expiry minutes a locked number stays locked + | resend_interval seconds before another code can be sent + | */ - 'number_prefix' => '+', + + 'code_generator' => VerificationCodeGenerator::class, + + 'code_length' => 6, + + 'verification_code_validity' => 10, + + 'max_attempts' => 5, + + 'attempt_expiry' => 30, + + 'resend_interval' => 30, /* |-------------------------------------------------------------------------- - | Mobile Number Format Validator + | Rate limits |-------------------------------------------------------------------------- - | This class that will be used to validate the mobile number format. - * */ - 'mobile_number_format_validator' => \Javaabu\MobileVerification\Support\MobileNumberFormatValidator::class, + | + | Requests per minute from one IP address, on top of the per-number + | limits above: `send` for requesting codes, `verify` for entering them. + | + */ + + 'rate_limits' => [ + 'send' => 5, + 'verify' => 10, + ], /* - |-------------------------------------------------------------------------- - | Verification code validity - |-------------------------------------------------------------------------- - | The number of minutes that a mobile number verification code - | would be valid for. + | Extra validation rules for requesting a code, e.g. a captcha: + | ['g-recaptcha-response' => ['required', 'captcha']] */ - 'verification_code_validity' => 10, // minutes + + 'request_code_rules' => [], /* |-------------------------------------------------------------------------- - | Maximum attempts + | The SMS |-------------------------------------------------------------------------- - | The maximum number of validation attempts that can be made before which - | the number would get locked for a specific length of time. + | + | Sent through javaabu/sms-notifications (config/sms.php picks the + | provider). Extend the class to change the message. + | */ - 'max_attempts' => 5, + + 'notification' => VerificationCodeNotification::class, /* |-------------------------------------------------------------------------- - | Attempt expiry + | Logging in |-------------------------------------------------------------------------- - | The number of minutes that a number would get locked for if there are too - | many verification attempts for that number. + | + | A code sent by SMS replaces the password, so users who turned on + | two-factor authentication still get its challenge. Set this to true to + | let the mobile login skip it. + | */ - 'attempt_expiry' => 30, // mins + + 'skip_two_factor' => false, /* |-------------------------------------------------------------------------- - | Resend interval + | Registration |-------------------------------------------------------------------------- - | The number of seconds after which a verification code can be resent to - | the same mobile number. + | + | Whether user types with mobile login and javaabu/auth's `registration` + | feature can also sign up with a mobile number, and the javaabu/stencil + | form that asks for their details once the number is verified. + | */ - 'resend_interval' => 30, + + 'registration' => true, + + 'registration_form' => MobileRegistrationForm::class, /* |-------------------------------------------------------------------------- - | Verified + | Controllers |-------------------------------------------------------------------------- - | Whether the mobile number should be verified + | + | Replace any controller with your own, usually extending the package's. + | */ - 'verified' => env('MOBILE_NUMBER_VERIFIED', true), + + 'controllers' => [ + 'login' => Controllers\MobileLoginController::class, + 'register' => Controllers\MobileRegistrationController::class, + 'mobile_number' => Controllers\MobileNumberController::class, + 'mobile_number_actions' => Controllers\MobileNumberActionsController::class, + 'api' => Controllers\MobileApiController::class, + ], /* |-------------------------------------------------------------------------- - | Use Recaptcha + | Authorization |-------------------------------------------------------------------------- - | Whether the use Recaptcha to prevent SPAM OTP requests + | + | The policy behind the admin actions on other users' numbers + | (Route::mobileNumberActions()), and the permissions registered with + | javaabu/permissions for each user type: all types when + | managed_user_types is null. + | */ - 'use_recaptcha' => env('MOBILE_NUMBER_USE_RECAPTCHA', true), + + 'policy' => MobileNumberPolicy::class, + + 'register_permissions' => true, + + 'managed_user_types' => null, /* |-------------------------------------------------------------------------- - | Notifications + | Passport |-------------------------------------------------------------------------- | - * */ - 'notifications' => [ - 'login' => \Javaabu\MobileVerification\Notifications\LoginVerificationTokenNotification::class, - 'register' => \Javaabu\MobileVerification\Notifications\RegisterVerificationTokenNotification::class, - 'verification_code' => \Javaabu\MobileVerification\Notifications\MobileNumberVerificationToken::class, - 'update' => \Javaabu\MobileVerification\Notifications\MobileNumberVerificationToken::class, - ], + | With laravel/passport installed, the package adds a `mobile` grant: + | an access token for a number and its code. Clients need the grant (or + | the password grant), and their provider must belong to a user type. + | + */ + + 'passport_grant' => true, - /* - * Token generator class - */ - 'verification_code_generator' => \Javaabu\MobileVerification\Support\VerificationCodeGenerator::class, ]; diff --git a/database/factories/MobileNumberFactory.php b/database/factories/MobileNumberFactory.php new file mode 100644 index 0000000..f3713db --- /dev/null +++ b/database/factories/MobileNumberFactory.php @@ -0,0 +1,62 @@ + + */ +class MobileNumberFactory extends Factory +{ + protected $model = MobileNumber::class; + + public function modelName(): string + { + return MobileVerification::mobileNumberModel(); + } + + public function definition(): array + { + return [ + 'country_code' => MobileVerification::defaultCountryCode(), + 'number' => $this->faker->unique()->numerify($this->faker->randomElement(['7######', '9######'])), + 'user_type' => 'user', + ]; + } + + /** + * Owned by a user, which verified it + */ + public function forUser(Model $user): static + { + return $this->state(fn () => [ + 'user_type' => $user->getMorphClass(), + ])->afterMaking(function (MobileNumber $mobile_number) use ($user) { + $mobile_number->user()->associate($user); + }); + } + + /** + * Without an owner, for a kind of user (its morph class) + */ + public function forUserType(string $morph_class): static + { + return $this->state(fn () => ['user_type' => $morph_class]); + } + + /** + * Locked after too many wrong codes + */ + public function locked(): static + { + return $this->state(fn () => [ + 'attempts' => (int) MobileVerification::config('max_attempts', 5), + ])->afterMaking(function (MobileNumber $mobile_number) { + $mobile_number->forceFill(['verification_code_created_at' => now()]); + }); + } +} diff --git a/database/migrations/2024_01_01_000000_create_mobile_numbers_table.php b/database/migrations/2024_01_01_000000_create_mobile_numbers_table.php new file mode 100644 index 0000000..2e03f97 --- /dev/null +++ b/database/migrations/2024_01_01_000000_create_mobile_numbers_table.php @@ -0,0 +1,43 @@ +hasTable($table_name)) { + return; + } + + $schema->create($table_name, function (Blueprint $table) { + $table->id(); + $table->nullableMorphs('user'); + $table->unsignedInteger('attempts')->default(0); + $table->string('number')->index(); + $table->string('country_code'); + $table->string('verification_code')->nullable(); + $table->dateTime('verification_code_created_at')->nullable(); + $table->uuid('verification_code_id')->nullable(); + $table->timestamps(); + + // One number per user + $table->unique(['user_id', 'user_type']); + + // One record per number and kind of user + $table->unique(['country_code', 'number', 'user_type']); + }); + } + + public function down(): void + { + Schema::connection(config('mobile-verification.database_connection')) + ->dropIfExists(config('mobile-verification.table_name', 'mobile_numbers')); + } +}; diff --git a/database/migrations/create_mobile_numbers_table.php b/database/migrations/create_mobile_numbers_table.php deleted file mode 100644 index d7ae345..0000000 --- a/database/migrations/create_mobile_numbers_table.php +++ /dev/null @@ -1,45 +0,0 @@ -create(config('mobile-verification.table_name'), function (Blueprint $table) { - $table->id(); - $table->nullableMorphs('user'); - $table->unsignedInteger('attempts')->default(0); - $table->string('number')->index(); - $table->string('country_code'); - $table->string('verification_code')->nullable(); - $table->dateTime('verification_code_created_at')->nullable(); - $table->uuid('verification_code_id')->nullable(); - $table->timestamps(); - - // 1 number per user - $table->unique(['user_id', 'user_type']); - - // 1 record for each number - $table->unique(['country_code', 'number', 'user_type']); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::connection(config('mobile-verification.database_connection'))->dropIfExists(config('mobile-verification.table_name')); - } -}; diff --git a/docs/advanced-usage/_category_.json b/docs/advanced-usage/_category_.json new file mode 100644 index 0000000..fb29de6 --- /dev/null +++ b/docs/advanced-usage/_category_.json @@ -0,0 +1,11 @@ +{ + "position": 1.31, + "label": "Advanced Usage", + "collapsible": true, + "collapsed": false, + "link": { + "type": "generated-index", + "slug": "/mobile-verification/_categories/advanced-usage", + "description": "Every option, codes and rate limits, the SMS, overrides, events, translations and the API reference." + } +} diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md new file mode 100644 index 0000000..5a7d0e1 --- /dev/null +++ b/docs/advanced-usage/api-reference.md @@ -0,0 +1,89 @@ +--- +title: API reference +sidebar_position: 7 +--- + +Namespaces are under `Javaabu\MobileVerification`. + +# MobileVerification + +| Method | Returns | +| --- | --- | +| `enableFor(string ...$types)` | Adds `MobileLoginMethod` to javaabu/auth user types; call before `Route::authRoutes()` | +| `enabledFor(UserType\|string $type)` | Whether the type has mobile login | +| `allowsRegistration(UserType $type)` | `registration` config and the type's `registration` feature | +| `find(UserType\|Model\|string $type, ?string $country_code, ?string $number)` | `?MobileNumber` of that kind of user | +| `findOrCreate(UserType\|Model\|string $type, ?string $country_code, string $number)` | The record, created without an owner | +| `userFor(UserType $type, ?MobileNumber $mobile_number)` | The owner, loaded through the type's guard provider | +| `morphClassFor(UserType\|Model\|string $type)` | The `user_type` column value | +| `normalizeNumber(?string)`, `normalizeCountryCode(?string)` | `'077 712-34'` → `'7771234'`, `'+960'` → `'960'` | +| `defaultCountryCode()`, `allowedCountryCodes()`, `numberPrefix()`, `countryCodeOptions()` | From config; options are `['960' => '+960']` | +| `mobileNumberModel()`, `newMobileNumber()` | The model class, a new instance | +| `skipsTwoFactor()` | `skip_two_factor` | +| `config(string $key = '', mixed $default = null)` | `config('mobile-verification.…')` | +| `ignoreMigrations()` | Don't load the package's migration | + +# Routes + +| Macro | Registers | +| --- | --- | +| `Route::authRoutes()` (javaabu/auth) | The mobile login and registration of types with `MobileLoginMethod` | +| `Route::mobileNumberRoutes(?string $type, array $options)` | The account page; options `prefix`, `middleware`, `domain`, `confirm_password` | +| `Route::mobileApiRoutes(?string $type, array $options)` | `mobile/code` and `mobile/token`; options `prefix`, `middleware` | +| `Route::mobileNumberActions(string $type, array $options)` | Admin actions; options `prefix`, `name`, `middleware` | + +Middleware: `mobile.verified:{type}` (also `mobile-verified`). + +Account navigation: the service provider registers `UserTypes::accountLink('mobile-number', 'Mobile number', position: 30)` (javaabu/auth); it shows for types with the `mobile-number` route. + +# Auth\MobileLoginMethod + +javaabu/auth `LoginMethod` with key `mobile` (`MobileLoginMethod::KEY`), label "Log in with your mobile number", `routes()` and `url()` (the type's `login.mobile`). + +# Models\MobileNumber + +| Member | | +| --- | --- | +| `user()` | `MorphTo` the owner | +| `formatted_number`, `short_formatted_number`, `prefix` | Attributes | +| `hasOwner()` | | +| `generateVerificationCode()` | A new code, saved hashed; returns the plain code | +| `hasVerificationCode()`, `checkVerificationCode(?string)` | A wrong code counts as an attempt | +| `clearVerificationCode()`, `resetAttempts()` | Forget the code (without and with saving) | +| `verificationCodeExpiresAt()`, `verificationCodeExpiresIn()`, `isVerificationCodeExpired()`, `codeValidity()` | Expiry (seconds, minutes) | +| `wasSentRecently()`, `resendVerificationCodeIn()`, `canRequestCode()` | Resend interval | +| `isLocked()`, `hasTooManyAttempts()`, `lockExpiresAt()`, `lockExpiresIn()`, `remainingAttempts()` | Locks | +| `isSame(MobileNumber)` | Same country code and number | +| `verificationCodeResponseData()` | The JSON the endpoints return | +| `routeNotificationForSms()` | The formatted number | +| Scopes `search()`, `hasNumber()`, `hasPhoneNumber()`, `hasPhoneNumberWithOwner()`, `hasPhoneNumberWithoutOwner()` | | + +# Concerns\InteractsWithMobileNumbers (Contracts\HasMobileNumber) + +`phone()`, `hasVerifiedMobile()`, `shouldVerifyPhone()`, `updatePhone()`, `updateMobileNumber()`, `removeMobileNumber()`, `clearPhones()`, `findByMobileNumber()`, `routeNotificationForSms()`; attributes `formatted_mobile_number`, `mobile_number`, `mobile_number_country_code`, `short_mobile_number`, `phone_verified`, `needs_phone_verification`; scopes `phoneVerified()`, `phoneUnverified()`, `hasMobileNumber()`, `hasShortMobileNumber()`, `hasPhone()`. See [The user model](../basic-usage/user-models.md). + +# Actions + +| Class | `__invoke` | +| --- | --- | +| `Actions\SendVerificationCode` | `(UserType $type, Purposes $purpose, ?string $country_code, string $number, ?Model $user = null, string $error_key = 'number'): MobileNumber` | +| `Actions\VerifyVerificationCode` | `(MobileNumber $mobile_number, ?string $verification_code, ?string $verification_code_id = null, string $error_key = 'verification_code'): void` | +| `Actions\AuthenticateMobileNumber` | `(UserType $type, array $input): Model` (`number`, `country_code`, `verification_code`, `verification_code_id`, `code`, `recovery_code`) | +| `Actions\CreateUserFromMobileNumber` | `create(UserType $type, array $input, MobileNumber $mobile_number): Model` | + +All throw `ValidationException`. + +# Other classes + +| Class | | +| --- | --- | +| `Enums\Purposes` | `LOGIN`, `REGISTER`, `UPDATE` | +| `Rules\IsValidMobileNumber`, `Rules\IsValidCountryCode` | [Validation rules](../basic-usage/validation-rules.md) | +| `Notifications\VerificationCodeNotification` | `(string $verification_code, Purposes $purpose, ?string $name)` | +| `Policies\MobileNumberPolicy` | `ABILITIES`, `DESCRIPTIONS`, `update()`, `unlock()`, `remove()`, `group(UserType)`, `permission(string $action, UserType)` | +| `Passport\MobileGrant` | The `mobile` grant | +| `Stencil\MobileRegistrationForm` | The registration form | +| `Support\PendingVerification` | The session state of a flow, per type and purpose | +| `Support\Strings` | The React strings | +| `Views\ViewRenderer` | `render(UserType $type, string $page, array $data, string $layout)`, `viewStack()`, `inertiaPage()`, `sharedData()` | +| `Http\Middleware\EnsureMobileIsVerified` | `mobile.verified` | diff --git a/docs/advanced-usage/codes-and-rate-limits.md b/docs/advanced-usage/codes-and-rate-limits.md new file mode 100644 index 0000000..1469e5a --- /dev/null +++ b/docs/advanced-usage/codes-and-rate-limits.md @@ -0,0 +1,68 @@ +--- +title: Codes and rate limits +sidebar_position: 2 +--- + +# Codes + +- Codes are `code_length` random digits (6 by default) from `random_int()`, a cryptographically secure source. +- They're stored hashed (`Hash::make()`), never in plain text, and left out of the model's array and JSON. +- A code expires `verification_code_validity` minutes (10) after it's sent, and is used up by the right entry, so it can't be entered twice. +- Each code has a `verification_code_id` (a UUID). The web flows keep it in the session, and the API sends it back; a code sent later (to another browser or app) makes the earlier one invalid. +- Requesting a new code makes the old one invalid. + +# Attempts and locks + +Each wrong code counts against the number, whichever browser it came from. After `max_attempts` (5) the number is locked: no code is accepted, not even the right one, and no new code is sent, until `attempt_expiry` minutes (30) after the last code was sent. `MobileNumberLocked` fires when that happens. Admins unlock numbers with the [unlock action](../basic-usage/managing-numbers.md), and code calls `$mobile_number->resetAttempts()`. + +A number gets one code per `resend_interval` seconds (30). + +# Rate limits + +On top of the per-number limits, two named rate limiters guard the routes: + +| Limiter | Default | Key | Routes | +| --- | --- | --- | --- | +| `mobile-verification.send` | 5 per minute | user type, IP | sending and resending codes | +| `mobile-verification.verify` | 10 per minute | user type, IP, session | entering codes, getting API tokens | + +Change the numbers in `rate_limits`, or redefine a limiter in a service provider's `boot()` (after the package's): + +```php +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; + +RateLimiter::for('mobile-verification.send', fn (Request $request) => [ + Limit::perMinute(3)->by($request->ip()), + Limit::perDay(20)->by($request->ip()), +]); +``` + +# Status codes for JSON + +| Case | Status | +| --- | --- | +| Validation errors, wrong or expired codes | 422 | +| Locked number, resend too soon | 429, with the message on `number` or `verification_code` | +| Rate limiter | 429 | +| A signed-in user on a guest route (`auth.guest`) | 409 | + +# Using the actions directly + +```php +use Javaabu\Auth\UserTypes; +use Javaabu\MobileVerification\Actions\SendVerificationCode; +use Javaabu\MobileVerification\Actions\VerifyVerificationCode; +use Javaabu\MobileVerification\Enums\Purposes; + +$type = UserTypes::get('user'); + +// Throws a ValidationException (on `number`) when locked, sent recently, or not allowed for the purpose +$mobile_number = app(SendVerificationCode::class)($type, Purposes::LOGIN, '960', '7771234'); + +// Throws a ValidationException (on `verification_code`); the right code is used up +app(VerifyVerificationCode::class)($mobile_number, '123456', $mobile_number->verification_code_id); +``` + +`Purposes::LOGIN` sends to numbers a user of the type has verified, `REGISTER` and `UPDATE` to numbers no other user of the type has (for `UPDATE`, pass the user as the fifth argument). diff --git a/docs/advanced-usage/configuration.md b/docs/advanced-usage/configuration.md new file mode 100644 index 0000000..b4b0c37 --- /dev/null +++ b/docs/advanced-usage/configuration.md @@ -0,0 +1,49 @@ +--- +title: Configuration +sidebar_position: 1 +--- + +Publish the config with `php artisan vendor:publish --tag=mobile-verification-config`. Every option has a default, so the file only needs what you change. + +| Option | Default | What | +| --- | --- | --- | +| `views` | `null` (env `MOBILE_VERIFICATION_VIEWS`) | `blade` or `inertia`; null follows the javaabu/auth user type | +| `database_connection` | `null` (env `MOBILE_NUMBER_DB_CONNECTION`) | Connection of the mobile numbers table; null is the default connection | +| `table_name` | `mobile_numbers` | The table | +| `mobile_number_model` | `Models\MobileNumber` | The model; must extend the package's | +| `default_country_code` | `960` (env `MOBILE_NUMBER_DEFAULT_COUNTRY_CODE`) | Used when no country code is given | +| `allowed_country_codes` | `['960']` (env `MOBILE_NUMBER_ALLOWED_COUNTRY_CODES`, comma separated) | Country codes users can choose | +| `number_prefix` | `+` | Shown before the country code | +| `number_format_validator` | `Support\NumberFormatValidator` | Implements `Contracts\ValidatesNumberFormat`; binding the contract wins | +| `code_generator` | `Support\VerificationCodeGenerator` | Implements `Contracts\GeneratesVerificationCodes`; binding the contract wins | +| `code_length` | `6` | Digits in a code (at least 4) | +| `verification_code_validity` | `10` | Minutes a code stays valid, and a verified registration number stays usable | +| `max_attempts` | `5` | Wrong codes before the number is locked | +| `attempt_expiry` | `30` | Minutes a locked number stays locked, from when its code was sent | +| `resend_interval` | `30` | Seconds before another code can be sent to a number | +| `rate_limits.send` | `5` | Code requests per minute per IP and user type | +| `rate_limits.verify` | `10` | Code entries per minute per IP, session and user type | +| `request_code_rules` | `[]` | Extra validation rules for requesting a code, e.g. a captcha | +| `notification` | `Notifications\VerificationCodeNotification` | The SMS | +| `skip_two_factor` | `false` | Let the mobile login skip javaabu/auth's two-factor challenge | +| `registration` | `true` | Mobile registration for types with javaabu/auth's `registration` feature | +| `registration_form` | `Stencil\MobileRegistrationForm` | The javaabu/stencil details form | +| `controllers` | the package's | `login`, `register`, `mobile_number`, `mobile_number_actions`, `api` | +| `policy` | `Policies\MobileNumberPolicy` | The admin actions' policy | +| `register_permissions` | `true` | Register the admin permissions with javaabu/permissions | +| `managed_user_types` | `null` | Types to register permissions for; null is every type | +| `passport_grant` | `true` | The `mobile` grant, when laravel/passport is installed | + +Where it fits, the package uses javaabu/auth's settings instead of its own: the user types, guards, homes, layouts (`javaabu-auth.layouts`, `layout_section`), the view stack, two-factor modes, `javaabu-auth.api` and `api_tokens`. + +# A captcha + +`request_code_rules` are added to the validation of every code request (login, registration, account page and API), so a captcha package plugs in without overriding controllers: + +```php +'request_code_rules' => [ + 'g-recaptcha-response' => ['required', 'captcha'], +], +``` + +Add the captcha field to the number forms by publishing the views. diff --git a/docs/advanced-usage/events.md b/docs/advanced-usage/events.md new file mode 100644 index 0000000..c6a25a1 --- /dev/null +++ b/docs/advanced-usage/events.md @@ -0,0 +1,28 @@ +--- +title: Events +sidebar_position: 5 +--- + +| Event | When | Properties | +| --- | --- | --- | +| `Events\VerificationCodeSent` | A code was sent | `mobile_number`, `purpose` (`Enums\Purposes`) | +| `Events\VerificationCodeFailed` | A code was wrong, expired, for an older code, or the number was locked | `mobile_number`, `reason` (`incorrect`, `expired`, `mismatch`, `locked`) | +| `Events\MobileNumberLocked` | A wrong code locked the number | `mobile_number` | +| `Events\MobileNumberUpdated` | A user's verified number changed or was removed | `old_number`, `new_number` (formatted, or null), `user`, `causer` | + +The events never carry the code. javaabu/auth's and Laravel's events fire as usual: `Login` after the mobile login, `Registered` after the mobile registration, and `TwoFactorAuthenticationChallenged` when the two-factor challenge follows. + +Log number changes with spatie/laravel-activitylog, for example: + +```php +use Illuminate\Support\Facades\Event; +use Javaabu\MobileVerification\Events\MobileNumberUpdated; + +Event::listen(function (MobileNumberUpdated $event) { + activity() + ->performedOn($event->user) + ->causedBy($event->causer) + ->withProperties(['old' => $event->old_number, 'new' => $event->new_number]) + ->log('mobile_number_updated'); +}); +``` diff --git a/docs/advanced-usage/overriding.md b/docs/advanced-usage/overriding.md new file mode 100644 index 0000000..09a96cd --- /dev/null +++ b/docs/advanced-usage/overriding.md @@ -0,0 +1,85 @@ +--- +title: Overriding controllers, actions and views +sidebar_position: 4 +--- + +# Controllers + +Every route's controller comes from config. Extend the package's and set yours: + +```php +// config/mobile-verification.php +'controllers' => [ + 'login' => App\Http\Controllers\MobileLoginController::class, + 'register' => Javaabu\MobileVerification\Http\Controllers\MobileRegistrationController::class, + 'mobile_number' => Javaabu\MobileVerification\Http\Controllers\MobileNumberController::class, + 'mobile_number_actions' => Javaabu\MobileVerification\Http\Controllers\MobileNumberActionsController::class, + 'api' => Javaabu\MobileVerification\Http\Controllers\MobileApiController::class, +], +``` + +The login, registration and account controllers extend `VerificationFlowController`, which has the shared steps (`store` sends a code, `verify` shows the code page, `confirm` checks it, `resend`) and calls `verified()` once the code is right: + +```php +namespace App\Http\Controllers; + +use Illuminate\Http\Request; +use Javaabu\MobileVerification\Http\Controllers\MobileLoginController as BaseController; +use Javaabu\MobileVerification\Models\MobileNumber; +use Symfony\Component\HttpFoundation\Response; + +class MobileLoginController extends BaseController +{ + protected function verified(Request $request, MobileNumber $mobile_number): Response + { + activity()->performedOn($mobile_number)->log('Logged in with a mobile number'); + + return parent::verified($request, $mobile_number); + } +} +``` + +Controllers are read when the routes are registered, so set them before `Route::authRoutes()` runs (config is). + +# Actions and contracts + +| Contract or class | Default | For | +| --- | --- | --- | +| `Contracts\CreatesUsersFromMobileNumbers` | `Actions\CreateUserFromMobileNumber` | Creating users at the end of registration | +| `Contracts\GeneratesVerificationCodes` | `Support\VerificationCodeGenerator` | The codes | +| `Contracts\ValidatesNumberFormat` | `Support\NumberFormatValidator` | Number formats per country code | +| `Actions\SendVerificationCode` | | Checks, generates and sends a code | +| `Actions\VerifyVerificationCode` | | Checks a code, counts attempts, locks | +| `Actions\AuthenticateMobileNumber` | | The API and Passport grant: number, code, account status, two-factor | + +Bind your own in a service provider's `register()`: + +```php +$this->app->bind(\Javaabu\MobileVerification\Contracts\ValidatesNumberFormat::class, \App\Support\NumberFormat::class); +``` + +javaabu/auth's responses (`LoginResponse`, `RegisterResponse`, `TwoFactorChallengeResponse`) decide where users go after the mobile login and registration too; rebind them there. + +# The registration form + +Set `registration_form` to your own javaabu/stencil form class; see [Registration](../basic-usage/registration.md#the-form). + +# Blade views + +```bash +php artisan vendor:publish --tag=mobile-verification-views +``` + +Edit `resources/views/vendor/mobile-verification/login.blade.php` and the others. The layouts are javaabu/auth's, per user type. + +# React pages + +```bash +php artisan manifest:override mobile-verification Pages/Login.tsx +``` + +Or return your own page from `resolvePage` for `mobile-verification::Login`. Keep the props in [Pages](../basic-usage/pages.md). + +# Different stacks + +The stack follows the user type, so admins can use Blade while public users get React. `mobile-verification.views` overrides it for every type. diff --git a/docs/advanced-usage/sms.md b/docs/advanced-usage/sms.md new file mode 100644 index 0000000..39deb17 --- /dev/null +++ b/docs/advanced-usage/sms.md @@ -0,0 +1,79 @@ +--- +title: The SMS +sidebar_position: 3 +--- + +Codes are sent as `Javaabu\MobileVerification\Notifications\VerificationCodeNotification` to the number (the `MobileNumber` model is notifiable), on the channel of javaabu/sms-notifications' `sms.default` driver. + +The message, in the locale of the request: + +> Your Javaabu verification code is 123456. It expires in 10 minutes. Do not share it with anyone. + +> Javaabu ގެ ވެރިފިކޭޝަން ކޯޑަކީ 123456. މި ކޯޑުގެ މުއްދަތު 10 މިނެޓުން ހަމަވާނެއެވެ. މި ކޯޑު އެހެން މީހަކާ ހިއްސާ ނުކުރައްވާ. + +`:app` is `config('app.name')`. Thaana messages are sent as Unicode, so each SMS part holds fewer characters (70 rather than 160); keep a long app name in mind. + +# Changing the message + +Change the translation in your `lang/dv.json` or `lang/en.json` (same key), or extend the class: + +```php +namespace App\Notifications; + +use Javaabu\MobileVerification\Enums\Purposes; +use Javaabu\MobileVerification\Notifications\VerificationCodeNotification as BaseNotification; + +class VerificationCodeNotification extends BaseNotification +{ + public function toSms($notifiable): string + { + return match ($this->purpose) { + Purposes::LOGIN => __(':code is your login code for :app', ['code' => $this->verification_code, 'app' => config('app.name')]), + default => parent::toSms($notifiable), + }; + } +} +``` + +```php +// config/mobile-verification.php +'notification' => App\Notifications\VerificationCodeNotification::class, +``` + +The constructor gets the code, the purpose (`Purposes::LOGIN`, `REGISTER`, `UPDATE`) and the user's name when there is one. Add `ShouldQueue` to send it on the queue. + +# Testing + +```php +use Illuminate\Support\Facades\Notification; +use Javaabu\MobileVerification\Models\MobileNumber; +use Javaabu\MobileVerification\Notifications\VerificationCodeNotification; + +Notification::fake(); + +$this->post('/login/mobile', ['number' => '7771234']); + +$code = null; + +Notification::assertSentTo( + MobileNumber::query()->hasNumber('7771234')->sole(), + VerificationCodeNotification::class, + function (VerificationCodeNotification $notification) use (&$code) { + $code = $notification->verification_code; + + return true; + }, +); + +$this->post('/login/mobile/verify', ['verification_code' => $code])->assertRedirect('/dashboard'); +``` + +Or bind a fixed code generator in tests: + +```php +use Javaabu\MobileVerification\Contracts\GeneratesVerificationCodes; + +$this->app->bind(GeneratesVerificationCodes::class, fn () => new class implements GeneratesVerificationCodes { + public function generate(): string { return '123456'; } +}); +``` diff --git a/docs/advanced-usage/translations.md b/docs/advanced-usage/translations.md new file mode 100644 index 0000000..964718c --- /dev/null +++ b/docs/advanced-usage/translations.md @@ -0,0 +1,34 @@ +--- +title: Dhivehi and translations +sidebar_position: 6 +--- + +Every user-facing string is an English `__()` key, with Dhivehi in the package's `lang/dv.json` (and the English in `lang/en.json`). The Blade pages, the React pages (their `strings` prop, from `Support\Strings`), validation messages and the SMS all use them, so one file covers both stacks. A test fails when a string has no Dhivehi or its placeholders differ. + +Change a line in your app's `lang/dv.json` with the same key, or publish the package's: + +```bash +php artisan vendor:publish --tag=mobile-verification-translations +``` + +Strings javaabu/auth also uses ("Log in", "Sign up", "Name", "Email address", the two-factor messages) have javaabu/auth's Dhivehi, so the pages read the same. + +The SMS goes out in the locale of the request that asked for it. Numbers and codes stay left to right in Dhivehi pages; see [Pages](../basic-usage/pages.md#right-to-left). + +The admin permission descriptions are registered in English ("Change the mobile numbers of users") and translated by javaabu/permissions when it shows them. + +# For review + +The Dhivehi was written without a native speaker's review. These choices especially need one: + +| English | Dhivehi | Question | +| --- | --- | --- | +| Mobile number | މޯބައިލް ނަންބަރު | v2 spelling kept; some apps write މޮބައިލް | +| Your :app verification code is :code. … | :app ގެ ވެރިފިކޭޝަން ކޯޑަކީ :code. … | Natural for an SMS? Shorter is cheaper in Unicode | +| This number is locked after too many wrong codes. … | ގިނަ ފަހަރު ކޯޑު ގޯސްކޮށް ޖެއްސުމުން މި ނަންބަރު ބަންދުކުރެވިފައިވެއެވެ. … | "ބަންދު" for locked | +| The code is incorrect. You have :count attempts left. | ކޯޑު ރަނގަޅެއް ނޫނެވެ. އިތުރަށް :count ފަހަރު މަސައްކަތްކުރެވިދާނެއެވެ. | One form for singular and plural | +| Verify | ވެރިފައި ކުރޭ | Or ތަސްދީޤުކުރޭ? | +| Country code | ޤައުމީ ކޯޑު | | +| Optional | ލާޒިމެއް ނޫން | | +| We will send you a code by SMS. | އެސްއެމްއެސްއިން ކޯޑެއް ފޮނުވާނަމެވެ. | | +| Your number :number is verified. Tell us a little about yourself. | … ތިޔަ ފަރާތާ ބެހޭ މަޢުލޫމާތު ދެއްވާ. | | diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md new file mode 100644 index 0000000..24e30ea --- /dev/null +++ b/docs/ai-assistants.md @@ -0,0 +1,32 @@ +--- +title: AI assistants +sidebar_position: 1.35 +--- + +The docs are plain Markdown, written for people and AI coding assistants alike. The package ships them, so they always match the version you have installed. + +| File | What | +| --- | --- | +| `vendor/javaabu/mobile-verification/llms.txt` | The rules for using the package, and every page | +| `vendor/javaabu/mobile-verification/docs/` | The pages | +| `vendor/javaabu/mobile-verification/resources/boost/` | The Laravel Boost skill and guideline | + +# Laravel Boost + +[Laravel Boost](https://github.com/laravel/boost) finds the skill and guideline by itself in Composer packages. Run this after installing or updating the package: + +```bash +php artisan boost:update +``` + +Choose `javaabu/mobile-verification` (and `javaabu/auth`) if Boost asks which third-party packages to use. + +# Other assistants + +Point your assistant at the docs from the project's instructions file (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, `.github/copilot-instructions.md`): + +```md +## Mobile numbers + +This app uses javaabu/mobile-verification on top of javaabu/auth. Read vendor/javaabu/mobile-verification/llms.txt before changing mobile login, registration, verification codes, SMS or users' mobile numbers. +``` diff --git a/docs/basic-usage/_category_.json b/docs/basic-usage/_category_.json index 8801b32..48b0329 100644 --- a/docs/basic-usage/_category_.json +++ b/docs/basic-usage/_category_.json @@ -6,6 +6,6 @@ "link": { "type": "generated-index", "slug": "/mobile-verification/_categories/basic-usage", - "description": "Everything you need to know to get started!" + "description": "Mobile login, registration, verifying numbers, the API, the pages and admin actions." } } diff --git a/docs/basic-usage/api.md b/docs/basic-usage/api.md new file mode 100644 index 0000000..54dd4e5 --- /dev/null +++ b/docs/basic-usage/api.md @@ -0,0 +1,55 @@ +--- +title: API +sidebar_position: 5 +--- + +Mobile apps and other clients without a session trade a number and its code for a token. + +# Personal access tokens (Sanctum or Passport) + +```php +// routes/api.php +Route::mobileApiRoutes('user', ['prefix' => 'api', 'middleware' => ['api']]); +``` + +| Method | URL | Name | | +| --- | --- | --- | --- | +| POST | `api/mobile/code` | `api.mobile.code` | Send a login code | +| POST | `api/mobile/token` | `api.mobile.token` | A token for the number and code | + +Names get the type's prefix (`admin.api.mobile.code`). The token comes from javaabu/auth's `ApiTokens`, so the user model needs Sanctum's or Passport's `HasApiTokens`, and `javaabu-auth.api` must allow that driver (`php artisan auth:install --api=sanctum`). + +```http +POST /api/mobile/code +{"number": "7771234", "country_code": "960"} + +200 {"verification_code_id": "9b1c…", "formatted_number": "+9607771234", "expires_in": 600, "resend_in": 30, …} + +POST /api/mobile/token +{"number": "7771234", "verification_code": "123456", "verification_code_id": "9b1c…", "device_name": "Aisha's iPhone"} + +201 {"token": "1|q4Tb…", "token_type": "Bearer"} +``` + +`verification_code_id` ties the token request to the code the client asked for. Users with two-factor authentication also send `code` (from their authenticator app) or `recovery_code`, unless `skip_two_factor` is on. Pending and banned accounts get no token. Errors are `422` with Laravel's validation format, and `429` for locked numbers and resend limits. + +# The Passport `mobile` grant + +With laravel/passport installed, the package adds a `mobile` grant to Passport's token endpoint, for apps that use OAuth2 clients: + +```http +POST /oauth/token +grant_type=mobile&client_id=…&client_secret=…&number=7771234&country_code=960&verification_code=123456&scope= +``` + +```json +{"token_type": "Bearer", "expires_in": 31536000, "access_token": "…", "refresh_token": "…"} +``` + +- Clients need the `mobile` grant, or the password grant: `php artisan passport:client --password --provider=users`. +- The client's provider picks the user type: the type whose guard uses that provider. Clients of other providers get `invalid_client`. +- Send the login code first, with `POST login/mobile` (JSON) or `POST api/mobile/code`. `verification_code_id` is optional here. +- Two-factor users add `code` or `recovery_code`. +- Turn the grant off with `'passport_grant' => false`. + +Errors are OAuth2 errors: `invalid_request` with the message of the failing field (`hint`). diff --git a/docs/basic-usage/how-login-with-mobile-number.md b/docs/basic-usage/how-login-with-mobile-number.md deleted file mode 100644 index cc3c555..0000000 --- a/docs/basic-usage/how-login-with-mobile-number.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: How to Login with Mobile Number -sidebar_position: 1.5 ---- - -# How to Login with Mobile Number -You can use this package to log in users with their mobile numbers. Before diving into the implementation steps, let's see how the login process works. - -## Login Process -The login process with mobile number works as follows: - -1. A user sends a request to the application with their mobile number. -2. The package checks if the mobile number is registered. - 3. If the mobile number is not registered, the package returns a message that the mobile number is not registered. - 4. If the mobile number is registered, the package sends an OTP to the mobile number. -2. The user enters the OTP. -2. The package verifies the OTP. -3. The user is then authenticated and logged in. - -## Step 1: Create the Controller -Create a controller class that extends `Javaabu\Helpers\Http\Controllers\Controller` class, implements `Javaabu\MobileVerification\Contracts\LoginWithMobileNumberContract` interface and add the `Javaabu\MobileVerification\Traits\LoginsWithMobileNumber` trait. Below is an example of how you can create the controller in your application. - -```php - 'web', - 'prefix' => '/mobile-verification', -], function () { - - /* - * This route will show the form where the user will enter their - * mobile number and request for the verification code - * */ - Route::get('/login', [LoginController::class, 'showVerificationCodeRequestForm'])->name('mobile-verifications.login.create'); - - /* - * This route will handle the request and send the verification code - * if the mobile number is valid and registered and shows a form - * to enter the verification code if the mobile number is valid. - * */ - Route::post('/login', [LoginController::class, 'requestVerificationCode'])->name('mobile-verifications.login.store'); - - /* - * This route will handle the validation of the verification code sent from the - * previous route and log in the user if the verification code is valid. - * */ - Route::match(['PATCH', 'PUT'], '/login', [LoginController::class, 'verifyVerificationCode'])->name('mobile-verifications.login.update'); - -}); -``` - -## Step 3: Making The Requests - -### Requesting the OTP -Below is an example of how you can make the request in your application. - -| Attribute | Description | Type | Required | -|--------------|-------------------------------------------------------------------------------------|--------|----------| -| country_code | The country code of the mobile number. When not given, default country code is used | String | No | -| number | The mobile number to validate. | String | Yes | - -### Requesting To Login - -| Attribute | Description | Type | Required | -|-------------------|--------------------------------------------------------------------------------------------------|--------|----------| -| country_code | The country code of the mobile number. When not given, default country code is used | String | No | -| number | The mobile number to validate. | String | Yes | -| verification_code | The OTP sent to the mobile number. | String | Yes | -| verification_code_id | The ID of the verification code. This is automatically sent to the verification code entry form. | String | Yes | - - - - - - diff --git a/docs/basic-usage/how-register-with-mobile-number.md b/docs/basic-usage/how-register-with-mobile-number.md deleted file mode 100644 index e2206ed..0000000 --- a/docs/basic-usage/how-register-with-mobile-number.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -title: How to Register with Mobile Number -sidebar_position: 1.6 ---- - -# How to Register with Mobile Number -You can use this package to register users with their mobile numbers. Before diving into the implementation steps, let's see how the registration process works. - -## Registration Process -The registration process with mobile number works as follows: - -1. A guest user sends a request to the application with their mobile number. -2. The package checks if the mobile number is already registered. - 3. If the mobile number is already registered, the package returns a message that the mobile number is already registered. - 4. If the mobile number is not registered, the package sends an OTP to the mobile number. -5. The user then enters the OTP and any other details required for registration. -6. The package verifies the OTP. -7. The developer can then implement the logic to verify the other details and user registration. -8. The package will then assign the verified mobile number to the user registered in the previous step. -9. The user is then authenticated and logged in. - - -## Step 1: Create the Controller -Create a controller that extends the `Javaabu\Helpers\Http\Controllers\Controller` class, implements the `Javaabu\MobileVerification\Contracts\RegisterWithMobileNumberContract` interface and add `Javaabu\MobileVerification\Traits\RegistersWithMobileNumber` trait to it. Below is an example of how you can create the controller in your application. - -```php -name = $data['name']; - $user->email = $data['email']; - $user->save(); - - return $user; - } - - /* - * You are required to implement the getRegisterFieldsValidationRules method to return the validation rules - * for the fields you want to validate during registration. By default, the package validates - * the country_code, number, verification code and the verification code id. - * */ - public function getRegisterFieldsValidationRules(Request $request): array - { - return [ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], - ]; - } - - public function getVerificationCodeRequestFormView(): ?string - { - return "verification-code-request-form"; - } - - /* - * This is the view that will be returned when the - * user is required to enter the verification code - * */ - public function getVerificationCodeFormView(): ?string - { - return "verification-code-entry-form"; - } - - /* - * The URL to redirect to when the verification code is validated, - * and the user is registered. For example, the dashboard URL - * */ - public function verificationCodeSuccessRedirectUrl(): string - { - // TODO: Implement verificationCodeSuccessRedirectUrl() method. - } - - /* - * Provide the view to be used for the registration form, required fields - * for this form is documented under "Making the requests" section - * */ - public function getRegistrationFormView(): string - { - // TODO: Implement getRegistrationFormView() method. - } - - public function enableReCaptcha(): bool - { - return true; - } -} -``` - -## Step 2: Define the Routes -Define the routes in your application. Below is an example of how you can define the routes in your application. - -```php -use Illuminate\Support\Facades\Route; -use App\Http\Controllers\LoginController; - -Route::group([ - 'middleware' => 'web', - 'prefix' => '/mobile-verification', -], function () { - - /* - * This route will show the form where the user will enter their - * mobile number and request for the verification code - * */ - Route::get('/register', [RegisterController::class, 'showVerificationCodeRequestForm'])->name('mobile-verifications.register.create'); - - /* - * This route will handle the request and send the verification code - * if the mobile number is valid and not registered and shows a - * form to enter the verification code and other registration - * details, such as name, email, etc. This view is defined - * in the `getRegistrationFormView` method in the controller. - * */ - Route::post('/register', [RegisterController::class, 'requestVerificationCode'])->name('mobile-verifications.register.store'); - - /* - * This route will handle the validation of the verification code sent from the - * previous route and other registration details and register the user using - * `createUser` method in the controller, if the verification code and other - * details are valid. The user is then authenticated and logged in. - * */ - Route::match(['PATCH', 'PUT'],'/register', [RegisterController::class, 'register'])->name('mobile-verifications.register.update'); - -}); -``` - -## Step 3: Making The Requests - -### Requesting The Verification Code -Below is an example of how you can make the request in your application. - -| Attribute | Description | Type | Required | -|--------------|-------------------------------------------------------------------------------------|--------|----------| -| country_code | The country code of the mobile number. When not given, default country code is used | String | No | -| number | The mobile number to validate. | String | Yes | - - -### Requesting To Register -Below are the default required and optional fields for the registration form. You may add more fields to the registration form, however the validation rules for any additional fields must be defined in the `getRegisterFieldsValidationRules` method in the controller. - -| Attribute | Description | Type | Required | -| --- | --- |--------| --- | -| country_code | The country code of the mobile number. When not given, default country code is used | String | No | -| number | The mobile number to validate. | String | Yes | -| verification_code | The OTP sent to the mobile number. | String | Yes | -| verification_code_id | The ID of the verification code. This is automatically sent to the verification code entry form. | String | Yes | - - - - - diff --git a/docs/basic-usage/how-update-mobile-number.md b/docs/basic-usage/how-update-mobile-number.md deleted file mode 100644 index 11e2ab7..0000000 --- a/docs/basic-usage/how-update-mobile-number.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: How to Update with Mobile Number -sidebar_position: 1.7 ---- - -# How to Update Mobile Number -You can use this package to update a user's mobile number. Before diving into the implementation steps, let's see how the update process works. - -## Update Process -The update process with mobile number works as follows: - -1. An authenticated user sends a request to the application with their new mobile number. -2. The package checks if the mobile number is already registered. - 3. If the mobile number is already registered, the package returns a message that the mobile number is already registered. - 4. If the mobile number is not registered, the package sends an OTP to the new mobile number. -5. The user then enters the OTP. -6. The package verifies the OTP. -7. The package then assigns the verified mobile number to the user and disassociates the previous mobile number from the user. - - -## Step 1: Create the Controller -Create a controller that extends the `Javaabu\Helpers\Http\Controllers\Controller` class, implements `Javaabu\MobileVerification\Contracts\UpdateMobileNumberContract` interface and add `Javaabu\MobileVerification\Traits\UpdatesMobileNumber` trait to it. Below is an example of how you can create the controller in your application. - -```php - 'web', - 'prefix' => '/mobile-verification', -], function () { - - /* - * This route will show the form where the user will enter their - * new mobile number and request for the verification code - * */ - Route::get('/update', [MobileNumberUpdateController::class, 'showVerificationCodeRequestForm'])->name('mobile-verifications.update.create'); - - /* - * This route will handle the request and send the verification code - * if the mobile number is valid and not registered and shows a - * form to enter the verification code. This view is defined - * in the `getVerificationCodeFormView` method in the controller. - * */ - Route::post('/update', [MobileNumberUpdateController::class, 'requestVerificationCode'])->name('mobile-verifications.update.store'); - - /* - * This route will handle the validation of the verification code sent from the - * previous route and updates the user mobile number if the verification code - * is valid. The user is then redirected to the URL defined in the - * `verificationCodeSuccessRedirectUrl` method in the controller. - * */ - Route::match(['PATCH', 'PUT'],'/update', [MobileNumberUpdateController::class, 'verifyVerificationCode'])->name('mobile-verifications.update.update'); - -}); -``` - -## Step 3: Making The Requests -To update the mobile number, you need to make the following requests: - -### Requesting The Verification Code -Below is an example of how you can make the request in your application. - -| Attribute | Description | Type | Required | -|--------------|-------------------------------------------------------------------------------------|--------|----------| -| country_code | The country code of the mobile number. When not given, default country code is used | String | No | -| number | The mobile number to validate. | String | Yes | - - -### Verifying The Verification Code & Updating The Mobile Number -Below are the default required and optional fields for the registration form. You may add more fields to the registration form, however the validation rules for any additional fields must be defined in the `getRegisterFieldsValidationRules` method in the controller. - -| Attribute | Description | Type | Required | -| --- | --- |--------| --- | -| country_code | The country code of the mobile number. When not given, default country code is used | String | No | -| number | The mobile number to validate. | String | Yes | -| verification_code | The OTP sent to the mobile number. | String | Yes | -| verification_code_id | The ID of the verification code. This is automatically sent to the verification code entry form. | String | Yes | - - diff --git a/docs/basic-usage/logging-in.md b/docs/basic-usage/logging-in.md new file mode 100644 index 0000000..aa1f44f --- /dev/null +++ b/docs/basic-usage/logging-in.md @@ -0,0 +1,74 @@ +--- +title: Logging in +sidebar_position: 2 +--- + +With `MobileLoginMethod` on a user type, its login page gets a "Log in with your mobile number" link (`data-login-method="mobile"`), and users log in in two steps: + +1. **The number.** `GET login/mobile` asks for the number (and the country code, when more than one is allowed). `POST login/mobile` checks that a user of this type has verified it, sends a code by SMS, and remembers the number in the session. +2. **The code.** `GET login/mobile/verify` asks for the code. `POST login/mobile/verify` checks it, then hands over to javaabu/auth's `StartAuthenticatedSession`, exactly as the password form does: users with two-factor authentication get the challenge, otherwise they're logged in on the type's guard, the session is regenerated, and `LoginResponse` sends them to the intended page or the type's home. + +`POST login/mobile/resend` sends another code once the resend interval has passed. "Remember me" on the number page is kept for the login. + +# Routes + +Registered by `Route::authRoutes()` in the type's guest group, so they have its prefix, middleware, `auth.guest:{type}` and route-name prefix (`admin.login.mobile`): + +| Method | URL | Name | | +| --- | --- | --- | --- | +| GET | `login/mobile` | `login.mobile` | The number page | +| POST | `login/mobile` | `login.mobile.store` | Send a code (`throttle:mobile-verification.send`) | +| GET | `login/mobile/verify` | `login.mobile.verify` | The code page | +| POST | `login/mobile/verify` | `login.mobile.verify.store` | Check the code and log in (`throttle:mobile-verification.verify`) | +| POST | `login/mobile/resend` | `login.mobile.resend` | Send another code | + +Leave them out for one registration with `'except' => ['login_method:mobile']`: + +```php +Route::authRoutes('admin', ['prefix' => 'admin', 'except' => ['login_method:mobile']]); +``` + +# Two-factor authentication + +A code by SMS replaces the password, not the second factor, so users who turned on two-factor authentication still enter their authenticator code. To let the mobile login skip it (when the SMS is your second factor): + +```php +// config/mobile-verification.php +'skip_two_factor' => true, +``` + +# What can go wrong + +| Situation | Message (on `number` or `verification_code`) | +| --- | --- | +| No user of this type has the number | There is no account with this mobile number. | +| Wrong format (Maldivian numbers are 7 digits starting with 7 or 9) | The mobile number is not a valid mobile number. | +| A code was sent less than 30 seconds ago | A code was sent to this number recently. You can ask for another in :seconds seconds. | +| Wrong code | The code is incorrect. You have :count attempts left. | +| 5 wrong codes | This number is locked after too many wrong codes. Try again in :minutes minutes. | +| Code older than 10 minutes | The code has expired. Ask for a new code. | +| A newer code was sent (from another browser) | This code is no longer valid. Ask for a new code. | +| The session lost the number | Enter your mobile number again to get a new code. | + +Locks and resend limits answer `429` to JSON requests; see [Codes and rate limits](../advanced-usage/codes-and-rate-limits.md). + +The number page says when a number has no account. That tells visitors which numbers are registered, as the registration page must anyway; the rate limits keep it slow. + +# JSON + +Every step answers JSON when the request expects it, for single-page apps that use the session: + +```http +POST /login/mobile +{"number": "7771234"} + +200 {"formatted_number": "+9607771234", "verification_code_id": "…", "is_registered": true, + "expires_in": 600, "resend_in": 30, "remaining_attempts": 5, "is_locked": false, …} + +POST /login/mobile/verify +{"verification_code": "123456"} + +200 {"two_factor": false, "redirect": "https://example.com/dashboard"} +``` + +Mobile apps without a session use the [API](api.md) instead. diff --git a/docs/basic-usage/managing-numbers.md b/docs/basic-usage/managing-numbers.md new file mode 100644 index 0000000..a3e4448 --- /dev/null +++ b/docs/basic-usage/managing-numbers.md @@ -0,0 +1,81 @@ +--- +title: Managing numbers +sidebar_position: 7 +--- + +Admins help users who lost their number or locked it: set a new number without a code, unlock a locked number, or remove it. `Route::mobileNumberActions()` adds these for one user type. Put them behind the admins' middleware: + +```php +Route::mobileNumberActions('user', [ + 'prefix' => 'admin/users', + 'middleware' => ['web', 'auth.type:admin'], +]); +``` + +| Method | URL | Name | Ability | Permission | +| --- | --- | --- | --- | --- | +| PUT | `{prefix}/{account}/mobile-number` | `users.mobile-number.update` | `mobile-verification.update` | `update_user_mobile_numbers` | +| POST | `{prefix}/{account}/mobile-number/unlock` | `users.mobile-number.unlock` | `mobile-verification.unlock` | `unlock_user_mobile_numbers` | +| DELETE | `{prefix}/{account}/mobile-number` | `users.mobile-number.destroy` | `mobile-verification.remove` | `remove_user_mobile_numbers` | + +`{account}` is the user's route key. `update` takes `number` and `country_code`; the number must not belong to another user of the type. The name prefix defaults to the plural of the type (`users.mobile-number`); set `name` to change it. Each action answers JSON (`{"message": …}`) or redirects back with a status. Changes fire `MobileNumberUpdated` with the admin as the causer. + +```blade + + + + {{ __('Save') }} + + +@can('mobile-verification.unlock', [\Javaabu\Auth\UserTypes::get('user'), $user]) + + {{ __('Unlock') }} + +@endcan +``` + +# Permissions + +The package registers three permissions for every user type through javaabu/permissions, in the type's plural group (`users`, `admins`), next to javaabu/auth's: `update_{type}_mobile_numbers`, `unlock_{type}_mobile_numbers`, `remove_{type}_mobile_numbers`. Run `php artisan permissions:sync` after installing. Limit them to some types, or turn them off: + +```php +// config/mobile-verification.php +'managed_user_types' => ['user'], +'register_permissions' => false, +``` + +# The policy + +The abilities are defined on the gate and point at `Javaabu\MobileVerification\Policies\MobileNumberPolicy`, which checks the permission of the managed type. They take the managed type and the account: + +```php +use Illuminate\Support\Facades\Gate; +use Javaabu\Auth\UserTypes; + +Gate::allows('mobile-verification.update', [UserTypes::get('user'), $user]); +``` + +Replace it in config with a class extending the package's: + +```php +'policy' => App\Policies\MobileNumberPolicy::class, +``` + +```php +namespace App\Policies; + +use Illuminate\Database\Eloquent\Model; +use Javaabu\Auth\UserType; +use Javaabu\MobileVerification\Policies\MobileNumberPolicy as BasePolicy; + +class MobileNumberPolicy extends BasePolicy +{ + public function remove(Model $user, UserType $type, ?Model $account = null): bool + { + // Admins can't remove their own number from here + return parent::remove($user, $type, $account) && ! $account?->is($user); + } +} +``` + +Or redefine one ability in a service provider: `Gate::define('mobile-verification.unlock', fn ($user, $type, $account) => $user->isSuperAdmin())`. The package doesn't replace abilities that are already defined. diff --git a/docs/basic-usage/pages.md b/docs/basic-usage/pages.md new file mode 100644 index 0000000..637e1ee --- /dev/null +++ b/docs/basic-usage/pages.md @@ -0,0 +1,94 @@ +--- +title: Pages (Blade and React) +sidebar_position: 6 +--- + +Every page exists in Blade ([javaabu/forms](https://github.com/Javaabu/forms) v2 components) and in Inertia React ([@javaabu/forms](https://github.com/Javaabu/react-forms) and [@javaabu/onu](https://www.npmjs.com/package/@javaabu/onu)), with the same props. Which one renders follows the user type (`views` on the type, or `javaabu-auth.views`), so the mobile pages match the rest of the login pages. Set `mobile-verification.views` to `blade` or `inertia` to choose for this package alone. + +| Page | Blade view | React page | Layout | +| --- | --- | --- | --- | +| Mobile login | `mobile-verification::login` | `mobile-verification::Login` | `auth` | +| Mobile registration | `mobile-verification::register` | `mobile-verification::Register` | `auth` | +| The code (every flow) | `mobile-verification::verify` | `mobile-verification::Verify` | `auth`, or `account` when changing a number | +| Registration details | `mobile-verification::register-details` | `mobile-verification::RegisterDetails` | `auth` | +| Account page | `mobile-verification::settings.mobile-number` | `mobile-verification::Settings/MobileNumber` | `account` | + +# Props + +Every page gets javaabu/auth's shared props (`auth_type`, `urls`, `account_links`, `status`) and: + +```ts +mobile: { + country_codes: Record; // { "960": "+960" } + default_country_code: string; + number_prefix: string; + code_length: number; +} +strings: Record; // translated, React only +``` + +| Page | Props | +| --- | --- | +| Login | `can_register` | +| Verify | `purpose` (`login`, `register`, `update`), `title`, `mobile_number` (`formatted_number`, `expires_in`, `resend_in`, `remaining_attempts`), `verify_url`, `resend_url`, `change_url` | +| RegisterDetails | `form` (the stencil schema), `mobile_number.formatted_number` | +| Settings/MobileNumber | `current_number` (`number`, `country_code`, `formatted_number`) or `null` | + +The form fields are `country_code`, `number`, `remember` (login), and `verification_code`. + +# Blade + +The pages extend the user type's layouts from javaabu/auth (`javaabu-auth.layouts.auth` and `.account`, or the type's `layouts`), filling `javaabu-auth.layout_section`. The layout needs `@manifestStyles` and `@manifestScripts` (or manifest's auto-injection), as for javaabu/auth. + +The number input, as Blade and React render it: + +```blade +@include('mobile-verification::partials.number-input', ['label' => __('Mobile number')]) +``` + +```tsx +import { NumberInput } from "@javaabu/mobile-verification"; + + +``` + +With one allowed country code it's a `tel` input with the prefix in front and a hidden `country_code`; with more, a country code select next to it. + +Override a view with `php artisan vendor:publish --tag=mobile-verification-views`, then edit `resources/views/vendor/mobile-verification/…`. + +# Inertia React + +Set up Inertia as for javaabu/auth's pages: install `inertiajs/inertia-laravel`, the npm packages (`@javaabu/forms`, `@javaabu/onu`, `@inertiajs/react`, `react`, `react-dom`), and resolve package pages with javaabu/manifest: + +```tsx +// resources/js/app.tsx +import { createInertiaApp } from "@inertiajs/react"; +import { FormsProvider } from "@javaabu/forms"; +import { inertia } from "@javaabu/forms/inertia"; +import { resolvePage } from "@javaabu/manifest/inertia"; +import { createRoot } from "react-dom/client"; + +createInertiaApp({ + resolve: (name) => resolvePage(name, import.meta.glob("./pages/**/*.tsx")), + setup({ el, App, props }) { + createRoot(el).render( + + + , + ); + }, +}); +``` + +The account pages wrap themselves in javaabu/auth's `AccountLayout`, passing `account_links` as its `links`, so the navigation matches auth's own pages (see [the account navigation](verifying-numbers.md#the-account-navigation)). The pages import `@javaabu/auth` (for `AccountLayout`) and `@javaabu/stencil` (for `StencilForm`), which javaabu/manifest's Vite plugin resolves from the Composer packages. `php artisan manifest:check` lists missing npm packages. It asks for the React packages only in apps with `@inertiajs/react` in their `package.json`; Blade-only apps install nothing from npm for this package. + +Replace a page with `php artisan manifest:override mobile-verification Pages/Login.tsx`, or in `resolvePage`. Apps can reuse the components: + +```tsx +import { MobileCard, NumberInput, StatusAlert, t } from "@javaabu/mobile-verification"; +import type { VerifyPageProps } from "@javaabu/mobile-verification"; +``` + +# Right to left + +The pages use logical utilities only (`ms-`, `text-start`; a test rejects `ml-` and `text-left`). Numbers and codes are `dir="ltr"`, and numbers inside Dhivehi sentences are wrapped in ``, so `+9607771234` doesn't turn into `9607771234+`. The registration form's name field types Thaana on Dhivehi pages. diff --git a/docs/basic-usage/preparing-your-model.md b/docs/basic-usage/preparing-your-model.md deleted file mode 100644 index 540f9e3..0000000 --- a/docs/basic-usage/preparing-your-model.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Preparing Your Model -sidebar_position: 1.4 ---- - -## Preparing Your Model - -To associate a mobile number with a user, the user model must implement the following interface and trait: -```php - 'mobile-verified:web', -], function () { - Route::get('/dashboard', function () { - return view('dashboard'); - })->name('dashboard'); -}); - -``` diff --git a/docs/basic-usage/registration.md b/docs/basic-usage/registration.md new file mode 100644 index 0000000..2ab68ec --- /dev/null +++ b/docs/basic-usage/registration.md @@ -0,0 +1,100 @@ +--- +title: Registration +sidebar_position: 3 +--- + +User types with `MobileLoginMethod` and javaabu/auth's `registration` feature also let visitors sign up with a mobile number. The mobile login page links to it ("Don't have an account? Sign up"). + +1. **The number.** `GET register/mobile`, then `POST register/mobile` checks that no user of this type has the number and sends a code. +2. **The code.** `GET register/mobile/verify`, then `POST register/mobile/verify` checks it. The verified number is remembered in the session for `verification_code_validity` minutes. +3. **The details.** `GET register/mobile/details` shows the registration form. `POST register/mobile/details` validates it, creates the user, gives them the number, fires Laravel's `Registered` event, logs them in and answers with javaabu/auth's `RegisterResponse`. + +If someone else takes the number before the form is sent, the user goes back to the first step with "This mobile number is already registered." + +Turn mobile registration off for every type with `'registration' => false` in `config/mobile-verification.php`, or for one type with javaabu/auth's feature: `'features' => ['registration' => false]`. + +# Routes + +| Method | URL | Name | | +| --- | --- | --- | --- | +| GET, POST | `register/mobile` | `register.mobile`, `register.mobile.store` | The number | +| GET, POST | `register/mobile/verify` | `register.mobile.verify`, `register.mobile.verify.store` | The code | +| POST | `register/mobile/resend` | `register.mobile.resend` | Another code | +| GET, POST | `register/mobile/details` | `register.mobile.details`, `register.mobile.details.store` | The form | + +# The form + +The details form is a [javaabu/stencil](https://github.com/Javaabu/stencil) form, so one definition renders in Blade and React and validates on the server. The default, `Javaabu\MobileVerification\Stencil\MobileRegistrationForm`, asks for a name (required, typed in Thaana on Dhivehi pages) and an email address (optional, unique in the user table). + +Ask for more by extending it and setting it in config: + +```php +namespace App\Stencil; + +use Javaabu\MobileVerification\Stencil\MobileRegistrationForm as BaseForm; +use Javaabu\Stencil\Fields\Select; +use Javaabu\Stencil\Fields\TextInput; + +class MobileRegistrationForm extends BaseForm +{ + protected function definition(): array + { + return [ + ...parent::definition(), + TextInput::make('id_card')->label(__('ID card number'))->required()->rules(['regex:/^A\d{6}$/']), + Select::make('island_id')->label(__('Island'))->relationship('island', 'name')->required(), + ]; + } +} +``` + +```php +// config/mobile-verification.php +'registration_form' => App\Stencil\MobileRegistrationForm::class, +``` + +The form gets the user type's model class as its model (for `unique()` and relationships), and the type's name in its context: `$this->context('user_type')`. + +# Creating the user + +The form's validated values go to `Javaabu\MobileVerification\Contracts\CreatesUsersFromMobileNumbers`. The default saves them on a new user of the type with `forceFill()` (they're limited to the form's fields), lowercasing the email. Bind your own to set more: + +```php +namespace App\Actions; + +use Illuminate\Database\Eloquent\Model; +use Javaabu\Auth\UserType; +use Javaabu\MobileVerification\Contracts\CreatesUsersFromMobileNumbers; +use Javaabu\MobileVerification\Models\MobileNumber; + +class CreateUserFromMobileNumber implements CreatesUsersFromMobileNumbers +{ + public function create(UserType $type, array $input, MobileNumber $mobile_number): Model + { + $user = $type->newModel(); + + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'] ?? null, + 'id_card' => mb_strtoupper($input['id_card']), + ]); + + $user->approve(); + $user->save(); + + return $user; + } +} +``` + +```php +// AppServiceProvider::register() +$this->app->bind( + \Javaabu\MobileVerification\Contracts\CreatesUsersFromMobileNumbers::class, + \App\Actions\CreateUserFromMobileNumber::class, +); +``` + +Don't attach the number or log the user in: the package does both after `create()` returns. + +New users get javaabu/auth's default account status, `pending`, as with its own registration, and `auth.type` sends pending users to the "account not active" page. Approve them in your action (as above) if they may use the app straight away. diff --git a/docs/basic-usage/requesting-access-token-via-api.md b/docs/basic-usage/requesting-access-token-via-api.md deleted file mode 100644 index fb14094..0000000 --- a/docs/basic-usage/requesting-access-token-via-api.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Requesting Access Token via API -sidebar_position: 1.8 ---- - -# Requesting Access Token via API - -Request an access token using the API. - -```php -$response = $http->post('http://your-app.com/oauth/token', [ - 'grant_type' => 'mobile', - 'client_id' => $client_id, - 'client_secret' => $client_secret, - 'number' => $mobile_number, - 'country_code' => $country_code, - 'verification_code' => $verification_code, -]); -``` diff --git a/docs/basic-usage/user-models.md b/docs/basic-usage/user-models.md new file mode 100644 index 0000000..0f1965b --- /dev/null +++ b/docs/basic-usage/user-models.md @@ -0,0 +1,99 @@ +--- +title: The user model +sidebar_position: 1 +--- + +A user model with a mobile number implements `Javaabu\MobileVerification\Contracts\HasMobileNumber` and uses `Javaabu\MobileVerification\Concerns\InteractsWithMobileNumbers`: + +```php +namespace App\Models; + +use Javaabu\Auth\Models\User as BaseUser; +use Javaabu\MobileVerification\Concerns\InteractsWithMobileNumbers; +use Javaabu\MobileVerification\Contracts\HasMobileNumber; + +class Admin extends BaseUser implements HasMobileNumber +{ + use InteractsWithMobileNumbers; +} +``` + +A user has at most one verified number, the `phone` relation (a `MorphOne` to `Javaabu\MobileVerification\Models\MobileNumber`). A number record without a user is a number someone asked a code for but hasn't verified, or one a user gave up. + +# Reading the number + +```php +$user->phone; // ?MobileNumber +$user->hasVerifiedMobile(); // bool +$user->formatted_mobile_number; // '+9607771234' +$user->mobile_number; // '7771234' +$user->mobile_number_country_code; // '960' +$user->short_mobile_number; // '7771234', or '+947712345' for other country codes +$user->needs_phone_verification; // no verified number, and shouldVerifyPhone() +``` + +Override `shouldVerifyPhone()` for users who don't need a number (the `mobile.verified` middleware lets them through): + +```php +public function shouldVerifyPhone(): bool +{ + return ! $this->is_service_account; +} +``` + +# Finding users + +```php +User::findByMobileNumber('7771234'); // the default country code +User::findByMobileNumber('771234567', '94'); +User::query()->hasMobileNumber('7771234', '960')->get(); +User::query()->hasShortMobileNumber('+9607771234')->get(); // as users write it +User::query()->hasPhone('777')->get(); // a search +User::query()->phoneVerified()->get(); +User::query()->phoneUnverified()->get(); +``` + +# Changing the number in code + +These skip the verification code, for imports, seeders and admin tools. Users change their own number on the [account page](verifying-numbers.md), which checks a code. + +```php +$user->updateMobileNumber('9991234'); // null if another user of the same type has it +$user->updateMobileNumber('771234567', '94', causer: $admin); +$user->updatePhone($mobile_number); // a MobileNumber, or its id +$user->removeMobileNumber(causer: $admin); +``` + +Each fires `Javaabu\MobileVerification\Events\MobileNumberUpdated` with the old and new number, the user and the causer. Force deleting a user deletes their number; soft deleting keeps it, so a restored user still has it. + +# SMS notifications + +The trait routes the `sms` channels of javaabu/sms-notifications (`routeNotificationForSms()`, `routeNotificationForDhiraagu()`, `routeNotificationForTwilio()`) to the verified number, so any SMS notification works on the user: + +```php +$user->notify(new OrderShippedSms($order)); +``` + +# The MobileNumber model + +```php +use Javaabu\MobileVerification\MobileVerification; + +$mobile_number = MobileVerification::find('user', '960', '7771234'); // ?MobileNumber, for a user type +$mobile_number->formatted_number; // '+9607771234' +$mobile_number->user; // the owner, or null +$mobile_number->hasOwner(); +$mobile_number->isLocked(); +$mobile_number->lockExpiresIn(); // seconds +$mobile_number->wasSentRecently(); +$mobile_number->resendVerificationCodeIn(); // seconds +$mobile_number->isVerificationCodeExpired(); +$mobile_number->resetAttempts(); // unlock +``` + +Numbers are normalized when saved: `077 712-34` becomes `7771234`, and `+960` becomes `960`. The `user_type` column holds the user model's morph class. Replace the model with `mobile-verification.mobile_number_model` (it must extend the package's); the table and connection come from `table_name` and `database_connection`. A factory ships for tests: + +```php +MobileNumber::factory()->forUser($user)->create(['number' => '7771234']); +MobileNumber::factory()->forUserType((new User)->getMorphClass())->locked()->create(); +``` diff --git a/docs/basic-usage/validation-rules.md b/docs/basic-usage/validation-rules.md new file mode 100644 index 0000000..2474173 --- /dev/null +++ b/docs/basic-usage/validation-rules.md @@ -0,0 +1,65 @@ +--- +title: Validation rules +sidebar_position: 8 +--- + +# IsValidMobileNumber + +A number in the right format for its country code, read from the `country_code` input (or the default country code): + +```php +use Javaabu\MobileVerification\Rules\IsValidCountryCode; +use Javaabu\MobileVerification\Rules\IsValidMobileNumber; + +$request->validate([ + 'country_code' => ['nullable', 'string', new IsValidCountryCode], + 'number' => ['required', 'string', new IsValidMobileNumber], +]); +``` + +Also check whether it's a user's verified number, for a javaabu/auth user type: + +```php +(new IsValidMobileNumber('user'))->registered(); // must belong to a user +(new IsValidMobileNumber('user'))->notRegistered(); // must not belong to anyone else +(new IsValidMobileNumber('user'))->notRegistered()->ignore($user); // except this user +(new IsValidMobileNumber(UserTypes::get('admin'), 'phone_country_code')); // another country code input +(new IsValidMobileNumber(Admin::class, is_morph_class: true))->registered(); // a morph class instead of a type +``` + +The format check is `Javaabu\MobileVerification\Contracts\ValidatesNumberFormat`, by default 7 digits starting with 7 or 9 for +960, and 4 to 15 digits for other country codes. Bind your own, or set `number_format_validator`: + +```php +use Javaabu\MobileVerification\Contracts\ValidatesNumberFormat; + +class SriLankanNumbers implements ValidatesNumberFormat +{ + public function passes(string $country_code, string $number): bool + { + return match ($country_code) { + '960' => (bool) preg_match('/^[79]\d{6}$/', $number), + '94' => (bool) preg_match('/^7\d{8}$/', $number), + default => false, + }; + } +} +``` + +# IsValidCountryCode + +One of `allowed_country_codes`, with or without the prefix (`960`, `+960`). + +# Checking codes yourself + +Codes aren't checked by a validation rule, because a wrong code counts as an attempt. Use the actions: + +```php +use Javaabu\MobileVerification\Actions\SendVerificationCode; +use Javaabu\MobileVerification\Actions\VerifyVerificationCode; +use Javaabu\MobileVerification\Enums\Purposes; + +$mobile_number = app(SendVerificationCode::class)($type, Purposes::UPDATE, '960', '7771234', $user); + +// later; throws a ValidationException on `verification_code` +app(VerifyVerificationCode::class)($mobile_number, $request->input('verification_code'), $mobile_number->verification_code_id); +``` diff --git a/docs/basic-usage/verifying-numbers.md b/docs/basic-usage/verifying-numbers.md new file mode 100644 index 0000000..73e2da3 --- /dev/null +++ b/docs/basic-usage/verifying-numbers.md @@ -0,0 +1,64 @@ +--- +title: Verifying and changing numbers +sidebar_position: 4 +--- + +Signed-in users verify a number, or change theirs, on an account page. Register it per user type: + +```php +Route::mobileNumberRoutes('user'); +Route::mobileNumberRoutes('admin', [ + 'prefix' => 'admin/settings', + 'middleware' => ['web'], + 'confirm_password' => true, +]); +``` + +| Option | Default | What | +| --- | --- | --- | +| `prefix` | `settings` | URL prefix | +| `middleware` | `['web']` | Middleware before `auth.type:{type}`, which the routes always have | +| `domain` | none | Domain of the routes | +| `confirm_password` | `false` | Ask for the password first (`auth.password.confirm:{type}`). Leave it off for types whose users may have no password | + +| Method | URL | Name | | +| --- | --- | --- | --- | +| GET | `settings/mobile-number` | `mobile-number` | The current number and a form for a new one | +| POST | `settings/mobile-number` | `mobile-number.store` | Send a code to the new number | +| GET, POST | `settings/mobile-number/verify` | `mobile-number.verify`, `mobile-number.verify.store` | The code; the new number replaces the old one | +| POST | `settings/mobile-number/resend` | `mobile-number.resend` | Another code | + +Names get the type's prefix: `admin.mobile-number`. The pages use the type's `account` layout (javaabu/auth's `AccountLayout` in React). The old number stays until the code for the new one is entered; then `MobileNumberUpdated` fires with the user as the causer. + +A number another user of the same type has can't be taken ("This mobile number is already registered."). Users of different types can share a number. + +# The account navigation + +The package adds "Mobile number" to javaabu/auth's account navigation, after Profile and Security (position 30), for every user type that has the page: the types you called `Route::mobileNumberRoutes()` for. javaabu/auth's account layout and its React `AccountLayout` show it, marked as the current page while users are on it. If your app has its own account layout, include auth's navigation there and the link comes with it: + +```blade +@include('auth::partials.account-nav') +``` + +Move the link, rename it or remove it per type with javaabu/auth's API: + +```php +use Javaabu\Auth\UserTypes; + +UserTypes::get('admin')->accountLink('mobile-number', 'Phone', position: 15); +UserTypes::get('user')->withoutAccountLink('mobile-number'); +``` + +Its label is the English key "Mobile number" (Dhivehi in the package's `lang/dv.json`). + +# Requiring a verified number + +`mobile.verified:{type}` sends signed-in users without a verified number to the type's `mobile-number` page, then back to where they were going once the code is entered. JSON requests get a `403`. Put it after `auth.type`: + +```php +Route::middleware(['auth.type:user', 'mobile.verified:user'])->group(function () { + Route::get('orders', [OrdersController::class, 'index'])->name('orders.index'); +}); +``` + +Without a parameter it uses the route's user type, then the default one. Users whose `shouldVerifyPhone()` returns false pass. `mobile-verified`, the v2 name, still works. diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index cd1706c..404c65d 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -3,136 +3,139 @@ title: Installation & Setup sidebar_position: 1.2 --- -You can install the package via composer: +Set up [javaabu/auth](https://github.com/Javaabu/auth) v3 first: this package adds mobile login to its user types. + +# Install ```bash composer require javaabu/mobile-verification +php artisan migrate ``` -### Publishing Migrations +The package loads its migration, which creates the `mobile_numbers` table (it skips it when the table is already there, as after v2). To change it, publish it and stop the package from loading its own: + ```bash -php artisan vendor:publish --provider="Javaabu\MobileVerification\MobileVerificationServiceProvider" --tag="mobile-verification-migrations" +php artisan vendor:publish --tag=mobile-verification-migrations ``` -### Publishing Translations -```bash -php artisan vendor:publish --provider="Javaabu\MobileVerification\MobileVerificationServiceProvider" --tag="mobile-verification-translations" +```php +// AppServiceProvider::register() +\Javaabu\MobileVerification\MobileVerification::ignoreMigrations(); ``` -### Publishing Config +# The SMS provider + +Codes go out through [javaabu/sms-notifications](https://github.com/Javaabu/sms-notifications). Pick a provider in `.env` and install its channel: + ```bash -php artisan vendor:publish --provider="Javaabu\MobileVerification\MobileVerificationServiceProvider" --tag="mobile-verification-config" +composer require dash8x/dhiraagu-sms-notification # SMS_DRIVER=dhiraagu +# or +composer require laravel-notification-channels/twilio # SMS_DRIVER=twilio +``` + +In tests, `Notification::fake()` catches the SMS; see [The SMS](advanced-usage/sms.md). + +# The user model + +Each user model that has a mobile number implements `HasMobileNumber` with the `InteractsWithMobileNumbers` trait: + +```php +namespace App\Models; + +use Javaabu\Auth\Models\User as BaseUser; +use Javaabu\MobileVerification\Concerns\InteractsWithMobileNumbers; +use Javaabu\MobileVerification\Contracts\HasMobileNumber; + +class User extends BaseUser implements HasMobileNumber +{ + use InteractsWithMobileNumbers; +} +``` + +Numbers live in the `mobile_numbers` table, related to users by a morph, so admins and public users can each have the same number. See [The user model](basic-usage/user-models.md). + +Users who sign up with a mobile number have no password, and the default registration form doesn't require an email. Make both nullable on that table: + +```php +use Javaabu\Auth\Schema\UserSchema; + +Schema::create('users', function (Blueprint $table) { + UserSchema::columns($table, password_required: false, email_required: false); +}); +``` + +# Turning on mobile login + +Add the login method to each user type that logs in with a mobile number: + +```php +// config/javaabu-auth.php +use Javaabu\MobileVerification\Auth\MobileLoginMethod; + +'user_types' => [ + 'user' => [ + 'guard' => 'web', + 'route_name' => '', + 'home' => '/dashboard', + 'login_methods' => [MobileLoginMethod::class], + ], + 'admin' => [ + 'guard' => 'web_admin', + 'home' => '/admin', + 'features' => ['registration' => false], + 'login_methods' => [MobileLoginMethod::class], + ], +], +``` + +Or in a service provider, before the routes load: + +```php +use Javaabu\MobileVerification\MobileVerification; + +MobileVerification::enableFor('user', 'admin'); ``` -This is the default content of the config file: +`Route::authRoutes()` then registers the mobile login (and, for types with javaabu/auth's `registration` feature, the mobile registration) with the type's other guest routes, and the login page links to it: ```php - env('MOBILE_NUMBER_DB_CONNECTION'), - - /* - |-------------------------------------------------------------------------- - | Mobile Numbers table name - |-------------------------------------------------------------------------- - | This is the name of the table that will be created by the mobile numbers - | migration and used by the MobileNumber model shipped with this package. - */ - 'table_name' => 'mobile_numbers', - - /* - |-------------------------------------------------------------------------- - | Mobile Number Model - |-------------------------------------------------------------------------- - | This model will be used to save mobile numbers. - | It should implement the Javaabu\MobileVerification\Contracts\MobileNumber interface - | and extend Illuminate\Database\Eloquent\Model. - */ - 'mobile_number_model' => \Javaabu\MobileVerification\Models\MobileNumber::class, - - /* - |-------------------------------------------------------------------------- - | Default country code - |-------------------------------------------------------------------------- - | The country code to be used by default - */ - 'default_country_code' => env('MOBILE_NUMBER_DEFAULT_COUNTRY_CODE', '960'), - - /* - |-------------------------------------------------------------------------- - | Allowed country codes - |-------------------------------------------------------------------------- - | Which country codes are allowed - */ - 'allowed_country_codes' => explode(',', env('MOBILE_NUMBER_ALLOWED_COUNTRY_CODES', '960')), - - /* - |-------------------------------------------------------------------------- - | Number prefix - |-------------------------------------------------------------------------- - | The prefix to add before fully qualified numbers - */ - 'number_prefix' => '+', - - /* - |-------------------------------------------------------------------------- - | Token validity - |-------------------------------------------------------------------------- - | The number of minutes that a mobile number verification token - | would be valid for. - */ - 'verification_code_validity' => 10, // minutes - - /* - |-------------------------------------------------------------------------- - | Maximum attempts - |-------------------------------------------------------------------------- - | The maximum number of validation attempts that can be made before which - | the number would get locked for a specific length of time. - */ - 'max_attempts' => 5, - - /* - |-------------------------------------------------------------------------- - | Attempt expiry - |-------------------------------------------------------------------------- - | The number of minutes that a number would get locked for if there are too - | many verification attempts for that number. - */ - 'attempt_expiry' => 30, // minutes - - /* - |-------------------------------------------------------------------------- - | Resend interval - |-------------------------------------------------------------------------- - | The number of seconds after which a verification code can be resent to - | the same mobile number. - */ - 'resend_interval' => 30, // seconds - - /* - |-------------------------------------------------------------------------- - | Verified - |-------------------------------------------------------------------------- - | Whether the mobile number should be verified - */ - 'verified' => env('MOBILE_NUMBER_VERIFIED', true), - - /* - |-------------------------------------------------------------------------- - | Use Recaptcha - |-------------------------------------------------------------------------- - | Whether the use Recaptcha to prevent SPAM OTP requests - */ - 'use_recaptcha' => env('MOBILE_NUMBER_USE_RECAPTCHA', true), -]; +// routes/web.php +Route::authRoutes('user'); +Route::authRoutes('admin', ['prefix' => 'admin']); +``` + +Add the account page where signed-in users verify or change their number, and require a verified number where you need one: + +```php +Route::mobileNumberRoutes('user'); +Route::mobileNumberRoutes('admin', ['prefix' => 'admin/settings']); + +Route::middleware(['auth.type:user', 'mobile.verified:user'])->group(function () { + Route::get('dashboard', DashboardController::class)->name('dashboard'); +}); +``` + +That's all the common case needs: Maldivian numbers (+960), 6 digit codes valid for 10 minutes, a new code every 30 seconds, and a 30 minute lock after 5 wrong codes. Every default is in [Configuration](advanced-usage/configuration.md). + +# Pages + +The pages use the same stack and layouts as javaabu/auth's: Blade by default, Inertia React when the user type's `views` (or `javaabu-auth.views`) is `inertia`. Set `mobile-verification.views` to choose for this package alone. See [Pages](basic-usage/pages.md) for the React setup. + +# Publishing + +```bash +php artisan vendor:publish --tag=mobile-verification-config +php artisan vendor:publish --tag=mobile-verification-views +php artisan vendor:publish --tag=mobile-verification-translations +php artisan vendor:publish --tag=mobile-verification-migrations ``` + +# Permissions + +Admin actions on other users' numbers use javaabu/permissions. After installing, sync the permissions: + +```bash +php artisan permissions:sync +``` + +See [Managing numbers](basic-usage/managing-numbers.md). diff --git a/docs/intro.md b/docs/intro.md index 3560eff..7e6276e 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -5,12 +5,51 @@ sidebar_position: 1.0 # Mobile Verification -[Mobile Verification](https://github.com/Javaabu/mobile-verification) is a frontend agnostic authentication backend implementation for Laravel using mobile number verification. It provides a set of features to enable mobile number verification in your Laravel application. +[javaabu/mobile-verification](https://github.com/Javaabu/mobile-verification) lets users log in, sign up and verify their mobile number with a one-time code sent by SMS. It is built on [javaabu/auth](https://github.com/Javaabu/auth), so it works for every user type (public users, admins, staff, …) with that type's guard, routes, layouts and rules: -The package itself does not register any routes, views, or controllers. Instead, it provides a set of traits and base controllers that can be extended for different use cases. +- **Mobile login.** A javaabu/auth login method: the login page links to it, and once the code is right, javaabu/auth finishes the login. Two-factor authentication still applies. +- **Mobile registration.** Verify a number, then fill in a short form (a [javaabu/stencil](https://github.com/Javaabu/stencil) form you can replace) to create the account. +- **Verifying and changing numbers.** An account page for signed-in users, and a `mobile.verified` middleware for pages that need a verified number. +- **Safe codes.** Hashed, expiring codes, a resend interval, a lock after too many wrong codes, and rate limits per IP. +- **SMS** through [javaabu/sms-notifications](https://github.com/Javaabu/sms-notifications) (Dhiraagu, Twilio), in the user's language. +- **API.** Sanctum or Passport tokens for a number and code, and an optional Passport `mobile` grant. +- **Blade and Inertia React.** Every page in javaabu/forms Blade components and in React with @javaabu/forms, following the user type's choice. Dhivehi and right to left throughout. +- **Overridable.** Controllers from config, actions from the container, routes from macros, views publishable, and admin actions behind a policy and javaabu/permissions permissions. -Since the package is frontend-agnostic, it is meant to be paired with your own routes, user interface, and controllers. We will discuss exactly how to create routes and controllers in the remainder of this documentation. +```php +// config/javaabu-auth.php +use Javaabu\MobileVerification\Auth\MobileLoginMethod; -## When Should I Use This Package? -You may be wondering when it is appropriate to use this package. This package implements authentication using mobile number verification. If you are looking to implement mobile number verification in your Laravel application, this package is for you. You may use this package to authenticate one or more types of users. This does not restrict you from using other authentication types for other types of users. +'user_types' => [ + 'user' => [ + 'guard' => 'web', + 'route_name' => '', + 'home' => '/dashboard', + 'login_methods' => [MobileLoginMethod::class], + ], +], +``` +```php +// routes/web.php +Route::authRoutes('user'); // adds /login/mobile and /register/mobile +Route::mobileNumberRoutes('user'); // adds /settings/mobile-number + +Route::middleware(['auth.type:user', 'mobile.verified:user'])->group(function () { + Route::get('dashboard', DashboardController::class)->name('dashboard'); +}); +``` + +```php +// app/Models/User.php +use Javaabu\Auth\Models\User as BaseUser; +use Javaabu\MobileVerification\Concerns\InteractsWithMobileNumbers; +use Javaabu\MobileVerification\Contracts\HasMobileNumber; + +class User extends BaseUser implements HasMobileNumber +{ + use InteractsWithMobileNumbers; +} +``` + +Start with [Installation & Setup](installation-and-setup.md). Coming from v2? Read [Upgrading](upgrading.md). diff --git a/docs/metadata.json b/docs/metadata.json index 9b15af2..ae9bee4 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { "name": "Mobile Verification", - "description": "Adds mobile number verification to Laravel projects.", - "version": "1" + "description": "Mobile number login, registration and verification with one-time SMS codes, built on javaabu/auth user types", + "version": "3" } diff --git a/docs/requirements.md b/docs/requirements.md index 73bd6c0..28f9457 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,7 +3,25 @@ title: Requirements sidebar_position: 1.1 --- -This package requires the following: +- PHP 8.4 or 8.5 +- Laravel 12 or 13 +- [javaabu/auth](https://github.com/Javaabu/auth) v3, set up with at least one user type -- Laravel 9.0 or higher -- PHP 8.1 or higher +It installs: + +- [javaabu/auth](https://github.com/Javaabu/auth) v3 for user types, login and two-factor authentication +- [javaabu/sms-notifications](https://github.com/Javaabu/sms-notifications) to send the codes +- [javaabu/forms](https://github.com/Javaabu/forms) v2 for the Blade pages, [javaabu/stencil](https://github.com/Javaabu/stencil) for the registration form, and [javaabu/manifest](https://github.com/Javaabu/manifest) to ship the React pages +- [javaabu/permissions](https://github.com/Javaabu/permissions) v3 for the admin permissions + +Optional, each detected when installed: + +| Package | For | +| --- | --- | +| `inertiajs/inertia-laravel` ^2.0 or ^3.0 | The Inertia React pages | +| `laravel/sanctum` ^4.0 | Personal access tokens from `Route::mobileApiRoutes()` | +| `laravel/passport` ^13.0 | Personal access tokens, and the `mobile` OAuth2 grant | + +To send real SMSs, install the channel of your provider, as javaabu/sms-notifications explains: `dash8x/dhiraagu-sms-notification` for Dhiraagu or `laravel-notification-channels/twilio` for Twilio. + +The React pages need `react` and `react-dom` 18.3 or 19, `@inertiajs/react` 2 or 3, `@javaabu/forms` and `@javaabu/onu` in the app. `php artisan manifest:check` lists what's missing. The Blade pages need no npm packages: the package declares all of these for its pages only (javaabu/manifest's `for: 'pages'`), so `manifest:check` asks for them only when the app's `package.json` has an Inertia adapter. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..11e8613 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,108 @@ +--- +title: Upgrading from v2 +sidebar_position: 1.25 +--- + +v3 is a rewrite on top of [javaabu/auth](https://github.com/Javaabu/auth) v3. The table and the request field names (`number`, `country_code`, `verification_code`, `verification_code_id`) are the same; the controllers you wrote with v2's traits are replaced by the package's own. + +# Requirements + +- PHP 8.4+, Laravel 12+. +- javaabu/auth v3 (required now; it was a suggestion). Set up its user types first: see its upgrade guide. +- javaabu/helpers, javaabu/activitylog and javaabu/laravel-recaptcha are no longer installed. If your app used them through this package, require them yourself. +- laravel/passport is optional (it was required). Install it if you use the `mobile` grant. + +# Database + +Nothing to migrate. The package now loads its migration itself, and it skips the table when it exists. If you published v2's migration, keep it and call `MobileVerification::ignoreMigrations()`, or delete your copy. + +# Config + +Publish the new config and copy your values over. Renamed and removed keys: + +| v2 | v3 | +| --- | --- | +| `mobile_number_format_validator` | `number_format_validator` (implements `Contracts\ValidatesNumberFormat`) | +| `verification_code_generator` | `code_generator` (implements `Contracts\GeneratesVerificationCodes`) | +| `notifications` (`login`, `register`, `verification_code`, `update`) | `notification`: one class, given the purpose | +| `use_recaptcha` | removed: add a captcha rule to `request_code_rules` | +| `mobile_grant_allowed_providers` | removed: the grant serves the providers of javaabu/auth user types; `passport_grant` turns it off | +| `verified` | removed | +| | new: `views`, `code_length`, `rate_limits`, `request_code_rules`, `skip_two_factor`, `registration`, `registration_form`, `controllers`, `policy`, `register_permissions`, `managed_user_types` | + +# User models + +```diff +- use Javaabu\MobileVerification\Traits\InteractsWithMobileNumbers; ++ use Javaabu\MobileVerification\Concerns\InteractsWithMobileNumbers; + use Javaabu\MobileVerification\Contracts\HasMobileNumber; +``` + +The trait keeps `phone()`, the attributes, the scopes, `updatePhone()`, `updateMobileNumber()`, `clearPhones()` and `hasVerifiedMobile()`. Changes: + +- `updateMobileNumber()` returns the `MobileNumber`, or null when another user has the number, and takes a causer. +- New: `removeMobileNumber()`, `findByMobileNumber()`, `shouldVerifyPhone()` (replaces the `should_verify_phone` attribute and `scopeShouldVerifyPhone`). +- Removed: `phoneVerificationUrlPrefix()`, `redirectToMobileVerificationUrl()` (the middleware redirects to the account page itself). +- `HasMobileNumber` lists the methods the package calls. + +# The MobileNumber model + +- `Contracts\MobileNumber` is gone; a custom `mobile_number_model` extends `Models\MobileNumber`. +- It no longer logs activity (javaabu/activitylog). Listen to `MobileNumberUpdated` instead; see [Events](advanced-usage/events.md). +- `$appends` is only `formatted_number` (v2 also appended `verification_code_expires_in` and `resend_verification_code_in`). +- Attributes became methods: + +| v2 | v3 | +| --- | --- | +| `was_sent_recently` | `wasSentRecently()` | +| `resend_verification_code_in` | `resendVerificationCodeIn()` (seconds) | +| `is_verification_code_expired` | `isVerificationCodeExpired()` | +| `verification_code_expires_at` | `verificationCodeExpiresAt()` | +| `verification_code_expiry` | `verificationCodeExpiresIn()` (seconds) | +| `verification_code_expires_in` | `codeValidity()` (minutes) | +| `is_locked` | `isLocked()` | +| `can_request_code` | `canRequestCode()` | +| `attempts_expiry_at`, `attempts_expiry`, `attempts_expiry_seconds` | `lockExpiresAt()`, `lockExpiresIn()` (seconds) | +| `verifyVerificationCode($code, $reset)` | `checkVerificationCode($code)`, or the `VerifyVerificationCode` action | +| `randomVerificationCode()` | the code generator | +| `blankPhone($number, $country_code, $user_type)` | `MobileVerification::findOrCreate($type, $country_code, $number)` | +| `getUserByMobileNumber(...)` | `MobileVerification::find(...)` and `MobileVerification::userFor($type, $mobile_number)` | + +- `verificationCodeResponseData()`, which the JSON responses return: `expires_in` is now the seconds left (it was the validity in minutes, and `expiry_duration` the seconds left); `attempt_expiry` (minutes) is now `unlocks_in` (seconds); `user_type` is gone; `number`, `country_code`, `formatted_number` and `remaining_attempts` are new. `verification_code_id`, `is_registered`, `expires_at`, `resend_interval`, `resend_in`, `attempts` and `is_locked` are unchanged. + +# Controllers and routes + +v2 shipped traits for controllers you wrote and routed yourself (`LoginsWithMobileNumber`, `RegistersWithMobileNumber`, `UpdatesMobileNumber`, `SendsVerificationCode`, `VerifiesVerificationCode`, `HasRequestMobileNumber`, `HasUserType`, `UsesGuard`, `UsesSessionMobileNumber` and their contracts). They're gone. Delete your controllers and routes and: + +1. Add `MobileLoginMethod` to the user types ([Installation](installation-and-setup.md#turning-on-mobile-login)): `Route::authRoutes()` registers the login and registration. +2. Register the account page with `Route::mobileNumberRoutes()`. +3. Move what your controllers customised: the registration fields to a [stencil form](basic-usage/registration.md#the-form), `createUser()` to a `CreatesUsersFromMobileNumbers` action, other steps to a [controller from config](advanced-usage/overriding.md). + +The URLs changed (`login/mobile`, `register/mobile`, `settings/mobile-number`); update links and mobile apps that posted to your old routes. API clients can use `Route::mobileApiRoutes()`. + +v2's login logged users in directly. v3 hands over to javaabu/auth, so **two-factor authentication now applies** to mobile logins; set `skip_two_factor` to keep v2's behaviour. + +# Middleware + +`mobile-verified` still works; prefer `mobile.verified:{type}`. It redirects to the type's `mobile-number` page instead of calling `redirectToMobileVerificationUrl()` on the user. + +# Rules + +- `IsValidMobileNumber`'s first argument is a javaabu/auth user type (name or `UserType`); pass a morph class with `is_morph_class: true`. `canSendOtp()` is gone: `SendVerificationCode` checks locks and the resend interval. +- `IsValidVerificationCode` is gone: a wrong code counts as an attempt, which a validation rule shouldn't do. Use the `VerifyVerificationCode` action. + +# Notifications + +`LoginVerificationTokenNotification`, `RegisterVerificationTokenNotification` and `MobileNumberVerificationToken` are replaced by `VerificationCodeNotification($code, $purpose, $name)`. The message is translated (English and Dhivehi) and uses `config('app.name')` instead of `get_setting('app_name')`. + +# Passport + +The `mobile` grant takes the same parameters. The user type comes from the client's provider (the javaabu/auth type whose guard uses it), and users with two-factor authentication must send `code` or `recovery_code`. + +# Translations + +`lang/{locale}/strings.php` (keys like `mobile-verification::strings.validation.number.invalid`) became JSON translations with English keys (`lang/dv.json`). Republish and redo overrides with the new keys. + +# Views + +v2 had no views. v3 ships Blade and Inertia React pages; see [Pages](basic-usage/pages.md). diff --git a/docs/validation-rules/_category_.json b/docs/validation-rules/_category_.json deleted file mode 100644 index 5e4d13c..0000000 --- a/docs/validation-rules/_category_.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "position": 2.1, - "label": "Validation Rules", - "collapsible": true, - "collapsed": false, - "link": { - "type": "generated-index", - "slug": "/mobile-verification/_categories/validation-rules", - "description": "Validation Rules used in Mobile Verification" - } -} diff --git a/docs/validation-rules/mobile-number-rule.md b/docs/validation-rules/mobile-number-rule.md deleted file mode 100644 index 85b6191..0000000 --- a/docs/validation-rules/mobile-number-rule.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Mobile Number Validation Rule -sidebar_position: 2.2 ---- - -# Mobile Number Validation Rule -The package provides a validation rule to validate mobile numbers. The following validations are performed on the mobile number: -1. The mobile number format is validated. -2. Optionally check if the mobile number is registered in the database. -3. Optionally check if the system can send a verification code to the mobile number. - -## Usage - -You can use the `IsValidMobileNumber` validation rule to validate mobile numbers. Below is an example of how you can use the rule in your application: - -```php - [ - 'required', - (new IsValidMobileNumber( - 'user', // this is a required parameter that specifies the morph class of the user model - 'country_code' // this is an optional parameter you can pass if the country code input name is different from the default 'country_code' - )) - ->registered() // Check if the mobile number is registered - ->canSendOtp() // Check if the system can send an OTP to the mobile number - ] -]; - -``` - -### Other available Options -- You may also call the `notRegistered()` method to check if the mobile number is not registered in the database. -- You may also call the `setShouldBeRegisteredNumber()` and pass in a boolean value to set the `registered` or `notRegistered()` method to be called based on the boolean value. This is useful when you want to dynamically set the validation rule based on a condition. - -## Customizing Mobile Number Format Validation -The validation format is validated using another class defined in the configuration file. You can customize the validation format by creating a new class that implements the `Javaabu\MobileVerification\Contracts\IsANumberFormatValidator` interface and updating the configuration file to use the new class. - -Below is an example of how you can create a new class to validate the mobile number format: - -```php - \App\Support\MobileNumberFormatValidator::class, -``` diff --git a/docs/validation-rules/verification-code-rule.md b/docs/validation-rules/verification-code-rule.md deleted file mode 100644 index 7dacc10..0000000 --- a/docs/validation-rules/verification-code-rule.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Verification Code Validation Rule -sidebar_position: 2.3 ---- - -# Verification Code Validation Rule -The package provides a validation rule to validate verification code. The following validations are performed on the mobile number: -1. The verification code id provided with the verification code is validated. -2. Checks if the mobile number for the verification code is locked. -3. Checks if the verification code is expired. -4. Checks if the verification code is correct. - -## Usage -You can use the `IsValidVerificationCode` validation rule to validate verification codes. Below is an example of how you can use the rule in your application: - -```php - [ - 'required', - (new IsValidVerificationCode( - 'user', // required parameter that specifies the morph class of the user model - 'country_code', // optional parameter you can pass if the country code input name is different from the default 'country_code' - 'number', // optional parameter you can pass if the mobile number input name is different from the default 'number' - 'verification_code_id' // optional parameter you can pass if the verification code id input name is different from the default 'verification_code_id' - )) - /* - * Optionally set whether the verification code should be cleared and - * verification attempts should be reset, (default is false) - * */ - ->setShouldResetAttempts(true) - ] -]; - -``` diff --git a/lang/dv.json b/lang/dv.json new file mode 100644 index 0000000..34cac1b --- /dev/null +++ b/lang/dv.json @@ -0,0 +1,63 @@ +{ + "A code was sent to this number recently. You can ask for another in :seconds seconds.": "މި ނަންބަރަށް ދާދިފަހުން ކޯޑެއް ފޮނުވިއެވެ. :seconds ސިކުންތު ފަހުން އަލުން ކޯޑެއް ހޯދިދާނެއެވެ.", + "API tokens are not available for this account.": "މި އެކައުންޓަށް އޭޕީއައި ޓޯކަން ނުލިބޭނެ.", + "Already have an account?": "ކުރިން އެކައުންޓެއް ހަދާފައިވޭތޯ؟", + "Change number": "ނަންބަރު ބަދަލުކުރޭ", + "Change the mobile numbers of :resource": ":resource ގެ މޯބައިލް ނަންބަރު ބަދަލުކުރުން", + "Code": "ކޯޑު", + "Continue": "ކުރިއަށް", + "Country code": "ޤައުމީ ކޯޑު", + "Create account": "އެކައުންޓް ހަދާ", + "Don't have an account?": "އެކައުންޓެއް ނެތްތޯ؟", + "Email address": "އީމެއިލް އެޑްރެސް", + "Enter the code": "ކޯޑު ޖައްސަވާ", + "Enter the code from your authenticator app.": "އޮތެންޓިކޭޓަރ އެޕުން ލިބޭ ކޯޑު ޖައްސަވާ.", + "Enter the code to log in": "ލޮގިން ވުމަށް ކޯޑު ޖައްސަވާ", + "Enter the code to verify your number": "ނަންބަރު ވެރިފައި ކުރުމަށް ކޯޑު ޖައްސަވާ", + "Enter your mobile number again to get a new code.": "އައު ކޯޑެއް ހޯދުމަށް މޯބައިލް ނަންބަރު އަލުން ޖައްސަވާ.", + "Log in": "ލޮގިން ކުރޭ", + "Log in with a password instead": "ޕާސްވޯޑާއެކު ލޮގިން ކުރޭ", + "Log in with your mobile number": "މޯބައިލް ނަންބަރާއެކު ލޮގިން ކުރޭ", + "Mobile number": "މޯބައިލް ނަންބަރު", + "Name": "ނަން", + "New mobile number": "އައު މޯބައިލް ނަންބަރު", + "Optional": "ލާޒިމެއް ނޫން", + "Remember me": "ހަނދާންބަހައްޓާ", + "Remove the mobile numbers of :resource": ":resource ގެ މޯބައިލް ނަންބަރު ފޮހެލުން", + "Send a new code": "އައު ކޯޑެއް ފޮނުވާ", + "Send code": "ކޯޑު ފޮނުވާ", + "Sign up": "ސައިން އަޕް ކުރޭ", + "Sign up with your mobile number": "މޯބައިލް ނަންބަރާއެކު ސައިން އަޕް ކުރޭ", + "The :attribute is not a valid mobile number.": ":attribute އަކީ ރަނގަޅު މޯބައިލް ނަންބަރެއް ނޫނެވެ.", + "The code has expired. Ask for a new code.": "ކޯޑުގެ މުއްދަތު ހަމަވެއްޖެއެވެ. އައު ކޯޑެއް ހޯއްދަވާ.", + "The code is incorrect. You have :count attempt left.|The code is incorrect. You have :count attempts left.": "ކޯޑު ރަނގަޅެއް ނޫނެވެ. އިތުރަށް :count ފަހަރު މަސައްކަތްކުރެވިދާނެއެވެ.|ކޯޑު ރަނގަޅެއް ނޫނެވެ. އިތުރަށް :count ފަހަރު މަސައްކަތްކުރެވިދާނެއެވެ.", + "The country code is not allowed.": "މި ޤައުމީ ކޯޑު ބޭނުންކުރެވޭކަށް ނެތެވެ.", + "The mobile number has been changed to :number.": "މޯބައިލް ނަންބަރު :number އަށް ބަދަލުކުރެވިއްޖެއެވެ.", + "The mobile number has been removed.": "މޯބައިލް ނަންބަރު ފޮހެލެވިއްޖެއެވެ.", + "The mobile number has been unlocked.": "މޯބައިލް ނަންބަރު އަލުން ހުޅުވިއްޖެއެވެ.", + "The provided two-factor authentication code was invalid.": "ޖެއްސި ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ކޯޑު ރަނގަޅެއް ނޫން.", + "The provided two-factor recovery code was invalid.": "ޖެއްސި ރިކަވަރީ ކޯޑު ރަނގަޅެއް ނޫން.", + "There is no account with this mobile number.": "މި މޯބައިލް ނަންބަރާ ގުޅޭ އެކައުންޓެއް ނެތެވެ.", + "This code is no longer valid. Ask for a new code.": "މި ކޯޑު މިހާރު ބޭނުމެއް ނުކުރެވޭނެއެވެ. އައު ކޯޑެއް ހޯއްދަވާ.", + "This is already your mobile number.": "މިއީ މިހާރުވެސް ތިޔަ ފަރާތުގެ މޯބައިލް ނަންބަރެވެ.", + "This mobile number is already registered.": "މި މޯބައިލް ނަންބަރު ވަނީ ކުރިން ރަޖިސްޓަރީ ކުރެވިފައެވެ.", + "This number is locked after too many wrong codes. Try again in :minutes minutes.": "ގިނަ ފަހަރު ކޯޑު ގޯސްކޮށް ޖެއްސުމުން މި ނަންބަރު ބަންދުކުރެވިފައިވެއެވެ. :minutes މިނެޓު ފަހުން އަލުން މަސައްކަތްކުރައްވާ.", + "Unlock the locked mobile numbers of :resource": ":resource ގެ ބަންދުކުރެވިފައިވާ މޯބައިލް ނަންބަރު ހުޅުވުން", + "Verify": "ވެރިފައި ކުރޭ", + "Verify your mobile number to continue.": "ކުރިއަށް ދިއުމަށް މޯބައިލް ނަންބަރު ވެރިފައި ކުރައްވާ.", + "We sent a :length digit code to :number. It expires in :minutes minutes.": ":number އަށް :length ދިޖިޓުގެ ކޯޑެއް ފޮނުވީމެވެ. މި ކޯޑުގެ މުއްދަތު :minutes މިނެޓުން ހަމަވާނެއެވެ.", + "We sent a code to :number.": ":number އަށް ކޯޑެއް ފޮނުވީމެވެ.", + "We sent a new code to :number.": ":number އަށް އައު ކޯޑެއް ފޮނުވީމެވެ.", + "We will send you a code by SMS.": "އެސްއެމްއެސްއިން ކޯޑެއް ފޮނުވާނަމެވެ.", + "You can ask for a new code in :seconds seconds.": ":seconds ސިކުންތު ފަހުން އައު ކޯޑެއް ހޯދިދާނެއެވެ.", + "You have not verified a mobile number yet.": "ތިޔަ ފަރާތުން އަދި މޯބައިލް ނަންބަރެއް ވެރިފައި ނުކުރައްވައެވެ.", + "Your :app verification code is :code. It expires in :minutes minutes. Do not share it with anyone.": ":app ގެ ވެރިފިކޭޝަން ކޯޑަކީ :code. މި ކޯޑުގެ މުއްދަތު :minutes މިނެޓުން ހަމަވާނެއެވެ. މި ކޯޑު އެހެން މީހަކާ ހިއްސާ ނުކުރައްވާ.", + "Your details": "ތިޔަ ފަރާތުގެ މަޢުލޫމާތު", + "Your mobile number is :number.": "ތިޔަ ފަރާތުގެ މޯބައިލް ނަންބަރަކީ :number.", + "Your mobile number is now :number.": "ތިޔަ ފަރާތުގެ މޯބައިލް ނަންބަރު މިހާރު :number އަށް ބަދަލުވެއްޖެއެވެ.", + "Your mobile number is used to log in and for SMS notifications.": "ތިޔަ ފަރާތުގެ މޯބައިލް ނަންބަރު ބޭނުންކުރެވެނީ ލޮގިން ވުމަށާއި އެސްއެމްއެސް ނޮޓިފިކޭޝަންތަކަށެވެ.", + "Your number :number is verified. Tell us a little about yourself.": "ތިޔަ ފަރާތުގެ ނަންބަރު :number ވެރިފައި ކުރެވިއްޖެއެވެ. ތިޔަ ފަރާތާ ބެހޭ މަޢުލޫމާތު ދެއްވާ.", + "code": "ކޯޑު", + "country code": "ޤައުމީ ކޯޑު", + "mobile number": "މޯބައިލް ނަންބަރު" +} diff --git a/lang/dv/strings.php b/lang/dv/strings.php deleted file mode 100644 index bc34f36..0000000 --- a/lang/dv/strings.php +++ /dev/null @@ -1,30 +0,0 @@ - [ - 'number' => [ - 'required' => ':attribute އަކީ ކޮންމެހެން ފުރިހަމަ ކުރަންޖެހޭ ބައެކެވެ.', - 'invalid' => ':attribute ރަނގަޅެއް ނޫނެވެ.', - 'invalid_length' => ':attribute ގައި 7 ނަންބަރު ހުންނަންވާނެއެވެ.', - 'exists' => ':attribute ވަނީ ކުރިން ބޭނުންކުރެވިފައެވެ.', - 'doesnt-exist' => ':attribute ވުޖޫދުގައި ނެތެވެ.', - 'locked' => 'ގިނަ ފަހަރު މަތިން މަސައްކަތްކުރުމުގެ ސަބަބުން ނަންބަރު ވަނީ ބަންދުކުރެވިފައެވެ. :time ފަހުން އަލުން މަސައްކަތްކުރެއްވުން އެދެމެވެ.', - 'recently_sent' => 'ކޯޑު ވަނީ ދާދިފަހުން މި ނަންބަރަށް ފޮނުވިފައެވެ. :time ފަހުން އަލުން މަސައްކަތްކުރެއްވުން އެދެމެވެ.', - 'soft_deleted' => ':attribute އާ ގުޅިފައިވާ ޔޫޒާ ވަނީ ވަގުތީގޮތުން ފޮހެލެވިފައެވެ.', - ], - 'country_code' => [ - 'invalid' => 'ގައުމުގެ ކޯޑު ރަނގަޅެއް ނޫނެވެ.' - ], - 'verification_code' => [ - 'invalid' => 'ޓޯކަން ރަނގަޅެއް ނޫނެވެ.', - 'expired' => 'ޓޯކަންގެ މުއްދަތު ހަމަވެއްޖެއެވެ.', - 'locked' => 'ގިނަ ފަހަރު މަތިން މަސައްކަތްކުރުމުގެ ސަބަބުން ނަންބަރު ވަނީ ބަންދުކުރެވިފައެވެ. :time ފަހުން އަލުން މަސައްކަތްކުރެއްވުން އެދެމެވެ.' - ], - 'messages' => [ - 'verification_code_verified' => "ތިޔަ ފަރާތުގެ މޯބައިލް ނަންބަރު ވަނީ ކާމިޔާބުކަމާއެކު ވެރިފައިކުރެވިފައެވެ.", - 'verification_code_verified_title' => 'ވެރިފައިކުރުން ކާމިޔާބު', - 'mobile_number_updated' => 'ތިޔަ ފަރާތުގެ މޯބައިލް ނަންބަރު ވަނީ ކާމިޔާބުކަމާއެކު :mobile_number އަށް އަޕްޑޭޓްކުރެވިފައެވެ.', - 'mobile_number_updated_title' => 'މޯބައިލް ނަންބަރު އަޕްޑޭޓްކުރެވިއްޖެ', - ] - ] -]; diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..62ab775 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,63 @@ +{ + "A code was sent to this number recently. You can ask for another in :seconds seconds.": "A code was sent to this number recently. You can ask for another in :seconds seconds.", + "API tokens are not available for this account.": "API tokens are not available for this account.", + "Already have an account?": "Already have an account?", + "Change number": "Change number", + "Change the mobile numbers of :resource": "Change the mobile numbers of :resource", + "Code": "Code", + "Continue": "Continue", + "Country code": "Country code", + "Create account": "Create account", + "Don't have an account?": "Don't have an account?", + "Email address": "Email address", + "Enter the code": "Enter the code", + "Enter the code from your authenticator app.": "Enter the code from your authenticator app.", + "Enter the code to log in": "Enter the code to log in", + "Enter the code to verify your number": "Enter the code to verify your number", + "Enter your mobile number again to get a new code.": "Enter your mobile number again to get a new code.", + "Log in": "Log in", + "Log in with a password instead": "Log in with a password instead", + "Log in with your mobile number": "Log in with your mobile number", + "Mobile number": "Mobile number", + "Name": "Name", + "New mobile number": "New mobile number", + "Optional": "Optional", + "Remember me": "Remember me", + "Remove the mobile numbers of :resource": "Remove the mobile numbers of :resource", + "Send a new code": "Send a new code", + "Send code": "Send code", + "Sign up": "Sign up", + "Sign up with your mobile number": "Sign up with your mobile number", + "The :attribute is not a valid mobile number.": "The :attribute is not a valid mobile number.", + "The code has expired. Ask for a new code.": "The code has expired. Ask for a new code.", + "The code is incorrect. You have :count attempt left.|The code is incorrect. You have :count attempts left.": "The code is incorrect. You have :count attempt left.|The code is incorrect. You have :count attempts left.", + "The country code is not allowed.": "The country code is not allowed.", + "The mobile number has been changed to :number.": "The mobile number has been changed to :number.", + "The mobile number has been removed.": "The mobile number has been removed.", + "The mobile number has been unlocked.": "The mobile number has been unlocked.", + "The provided two-factor authentication code was invalid.": "The provided two-factor authentication code was invalid.", + "The provided two-factor recovery code was invalid.": "The provided two-factor recovery code was invalid.", + "There is no account with this mobile number.": "There is no account with this mobile number.", + "This code is no longer valid. Ask for a new code.": "This code is no longer valid. Ask for a new code.", + "This is already your mobile number.": "This is already your mobile number.", + "This mobile number is already registered.": "This mobile number is already registered.", + "This number is locked after too many wrong codes. Try again in :minutes minutes.": "This number is locked after too many wrong codes. Try again in :minutes minutes.", + "Unlock the locked mobile numbers of :resource": "Unlock the locked mobile numbers of :resource", + "Verify": "Verify", + "Verify your mobile number to continue.": "Verify your mobile number to continue.", + "We sent a :length digit code to :number. It expires in :minutes minutes.": "We sent a :length digit code to :number. It expires in :minutes minutes.", + "We sent a code to :number.": "We sent a code to :number.", + "We sent a new code to :number.": "We sent a new code to :number.", + "We will send you a code by SMS.": "We will send you a code by SMS.", + "You can ask for a new code in :seconds seconds.": "You can ask for a new code in :seconds seconds.", + "You have not verified a mobile number yet.": "You have not verified a mobile number yet.", + "Your :app verification code is :code. It expires in :minutes minutes. Do not share it with anyone.": "Your :app verification code is :code. It expires in :minutes minutes. Do not share it with anyone.", + "Your details": "Your details", + "Your mobile number is :number.": "Your mobile number is :number.", + "Your mobile number is now :number.": "Your mobile number is now :number.", + "Your mobile number is used to log in and for SMS notifications.": "Your mobile number is used to log in and for SMS notifications.", + "Your number :number is verified. Tell us a little about yourself.": "Your number :number is verified. Tell us a little about yourself.", + "code": "code", + "country code": "country code", + "mobile number": "mobile number" +} diff --git a/lang/en/strings.php b/lang/en/strings.php deleted file mode 100644 index d9b3187..0000000 --- a/lang/en/strings.php +++ /dev/null @@ -1,30 +0,0 @@ - [ - 'number' => [ - 'required' => 'The :attribute field is required.', - 'invalid' => 'The :attribute must start with 7 or 9', - 'invalid_length' => 'The :attribute must be 7 digits long.', - 'exists' => 'The :attribute has already been taken.', - 'doesnt-exist' => 'The :attribute does not exist.', - 'locked' => 'The number is locked due to too many attempts. You can try again in :time.', - 'recently_sent' => 'The code was recently sent to this number. You can try again in :time.', - 'soft_deleted' => 'The user associated with the :attribute is soft deleted.', - ], - 'country_code' => [ - 'invalid' => 'The country code is invalid.' - ], - 'verification_code' => [ - 'invalid' => 'The token is invalid.', - 'expired' => 'The token has expired.', - 'locked' => 'The number is locked due to too many attempts. You can try again in :time.' - ], - 'messages' => [ - 'verification_code_verified' => "Your mobile number has been successfully verified.", - 'verification_code_verified_title' => 'Verification Successful', - 'mobile_number_updated' => 'Your mobile number has been successfully updated to :mobile_number.', - 'mobile_number_updated_title' => 'Mobile Number Updated', - ] - ] -]; diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..055af47 --- /dev/null +++ b/llms.txt @@ -0,0 +1,46 @@ +# javaabu/mobile-verification + +> Mobile number login, registration and verification with one-time SMS codes for Laravel, built on javaabu/auth v3: mobile login is a javaabu/auth LoginMethod of any user type (two-factor authentication still applies), mobile registration ends in a javaabu/stencil details form, signed-in users verify or change their number on an account page, and `mobile.verified` guards pages that need one. Codes are hashed, expire, are limited by a resend interval, lock the number after too many wrong entries, and are rate limited per IP. SMS through javaabu/sms-notifications, Sanctum/Passport tokens and a Passport `mobile` grant for apps, admin actions behind javaabu/permissions permissions. Pages in Blade (javaabu/forms) and Inertia React (@javaabu/forms), in Dhivehi and right to left. + +Paths are relative to this file. In a project, that's vendor/javaabu/mobile-verification/. + +## Rules + +- Set up javaabu/auth user types first. Turn on mobile login per type with `'login_methods' => [Javaabu\MobileVerification\Auth\MobileLoginMethod::class]` in `config('javaabu-auth.user_types')`, or `MobileVerification::enableFor('user')` before the routes load. `Route::authRoutes()` then registers `login.mobile…` (and `register.mobile…` for types with javaabu/auth's `registration` feature) with the type's route-name prefix (`admin.login.mobile`). +- Don't write mobile login or OTP controllers by hand. Customise with a controller from `config('mobile-verification.controllers')` (extend the package's), the `CreatesUsersFromMobileNumbers`, `GeneratesVerificationCodes` and `ValidatesNumberFormat` bindings, the `registration_form` stencil class, or the `notification` class. +- The mobile login finishes with javaabu/auth's `StartAuthenticatedSession`, so two-factor users get the challenge; `skip_two_factor` opts out. Never call `Auth::login()` for a mobile login. +- User models implement `Javaabu\MobileVerification\Contracts\HasMobileNumber` with `Javaabu\MobileVerification\Concerns\InteractsWithMobileNumbers`. One verified number per user (`phone` relation, `mobile_numbers` table, morph `user`). Users of different types may share a number. Tables of types with mobile registration need nullable `password` (and `email` with the default form): `UserSchema::columns($table, password_required: false, email_required: false)`. +- Change a user's number in code with `updateMobileNumber()`/`updatePhone()`/`removeMobileNumber()`; users change their own on `Route::mobileNumberRoutes('user')` (`settings/mobile-number`, route `mobile-number`), which checks a code; the package adds it to javaabu/auth's account navigation (`UserTypes::accountLink('mobile-number', …)`, shown for types with the route; change it with `UserTypes::get($type)->accountLink()` / `withoutAccountLink('mobile-number')`). Protect pages that need a number with `mobile.verified:{type}` after `auth.type:{type}`. +- Send and check codes with `Actions\SendVerificationCode` and `Actions\VerifyVerificationCode` (they throw `ValidationException`); never compare codes yourself, store them in plain text, or check them in a validation rule (a wrong code counts as an attempt). +- Request fields are `number`, `country_code`, `verification_code`, `verification_code_id`; numbers are normalized (`077 712-34` → `7771234`, `+960` → `960`). Validate numbers with `Rules\IsValidMobileNumber` (`->registered()`, `->notRegistered()->ignore($user)`) and `Rules\IsValidCountryCode`. +- API clients use `Route::mobileApiRoutes('user')` (`POST api/mobile/code`, `POST api/mobile/token`, via javaabu/auth's `ApiTokens`) or the Passport `mobile` grant (user type from the client's provider). +- Admin actions come from `Route::mobileNumberActions('user', ['middleware' => ['web', 'auth.type:admin']])`, behind the `mobile-verification.update|unlock|remove` abilities taking `[$type, $account]` and the `{action}_{type}_mobile_numbers` permissions; run `php artisan permissions:sync`. +- Pages follow the user type's stack (`mobile-verification.views` overrides): Blade `mobile-verification::login`, …, React `mobile-verification::Login`, `Register`, `Verify`, `RegisterDetails`, `Settings/MobileNumber` through javaabu/manifest. Override with `vendor:publish --tag=mobile-verification-views` or `php artisan manifest:override mobile-verification `. +- Every user-facing string is an English `__()` key with Dhivehi in `lang/dv.json`. Keep numbers and codes `dir="ltr"` and use logical utilities (`ms-`, `text-start`) only. In tests, `Notification::fake()` and read the code from `VerificationCodeNotification::$verification_code`. + +## Docs + +- [Introduction](docs/intro.md): what the package does +- [Requirements](docs/requirements.md): PHP, Laravel, dependencies, optional packages, SMS channels, the npm packages (React pages only; Blade apps need none) +- [Installation & Setup](docs/installation-and-setup.md): install, the SMS provider, the user model and table, turning on mobile login, routes, publishing, permissions +- [Upgrading from v2](docs/upgrading.md): config, models, controllers, routes, rules, notifications and translations that changed +- [The user model](docs/basic-usage/user-models.md): the trait and contract, reading and finding numbers, changing them in code, the MobileNumber model and factory +- [Logging in](docs/basic-usage/logging-in.md): the steps, routes, two-factor, errors, JSON +- [Registration](docs/basic-usage/registration.md): the steps, routes, the stencil form, creating the user +- [Verifying and changing numbers](docs/basic-usage/verifying-numbers.md): Route::mobileNumberRoutes(), the account navigation link, and the mobile.verified middleware +- [API](docs/basic-usage/api.md): Route::mobileApiRoutes() tokens and the Passport mobile grant +- [Pages (Blade and React)](docs/basic-usage/pages.md): every page and its props, layouts, the number input, Inertia setup, overriding, RTL +- [Managing numbers](docs/basic-usage/managing-numbers.md): Route::mobileNumberActions(), permissions, the policy +- [Validation rules](docs/basic-usage/validation-rules.md): IsValidMobileNumber, IsValidCountryCode, number formats, checking codes +- [Configuration](docs/advanced-usage/configuration.md): every option in config/mobile-verification.php, a captcha +- [Codes and rate limits](docs/advanced-usage/codes-and-rate-limits.md): how codes are made, expire and lock, the rate limiters, status codes, the actions +- [The SMS](docs/advanced-usage/sms.md): the message, changing it, testing +- [Overriding](docs/advanced-usage/overriding.md): controllers, actions and contracts, views, React pages +- [Events](docs/advanced-usage/events.md): the events and logging them +- [Dhivehi and translations](docs/advanced-usage/translations.md): translations, strings to review +- [API reference](docs/advanced-usage/api-reference.md): every public class and method +- [AI assistants](docs/ai-assistants.md): Laravel Boost, and using these docs from other assistants +- [Questions and Security](docs/questions-and-security.md): where to get help +- [Changelog](docs/changelog.md): where changes are listed +- [About Us](docs/about-us.md): Javaabu +- [Contributing to the docs](docs/contributing-to-docs.md): how the docs are built diff --git a/package.json b/package.json new file mode 100644 index 0000000..2fda7c1 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "javaabu-mobile-verification-dev", + "private": true, + "description": "Development tooling for the Inertia React pages of javaabu/mobile-verification. Apps don't install this; they get the pages through javaabu/manifest.", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "check": "pnpm typecheck && pnpm test" + }, + "devDependencies": { + "@inertiajs/react": "^2.0.0", + "@javaabu/forms": "github:Javaabu/react-forms#next-gen", + "@javaabu/onu": "^0.2.1", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": ["@javaabu/forms", "@javaabu/i18n", "esbuild"] + }, + "packageManager": "pnpm@10.28.0" +} diff --git a/pint.json b/pint.json index 99f0d70..44df8bf 100644 --- a/pint.json +++ b/pint.json @@ -1,14 +1,20 @@ { - "preset": "psr12", + "preset": "laravel", "rules": { + "concat_space": { + "spacing": "one" + }, + "global_namespace_import": { + "import_classes": true, + "import_constants": false, + "import_functions": false + }, + "no_unused_imports": true, "ordered_imports": { - "sort_algorithm": "length" + "sort_algorithm": "alpha" }, - "ordered_traits": true, - "no_extra_blank_lines": { - "tokens": [ - "use" - ] + "php_unit_method_casing": { + "case": "snake_case" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..57652dc --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4326 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@inertiajs/react': + specifier: ^2.0.0 + version: 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/forms': + specifier: github:Javaabu/react-forms#next-gen + version: https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/onu': + specifier: ^0.2.1 + version: 0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4) + '@testing-library/jest-dom': + specifier: ^6.6.0 + version: 6.10.0(@testing-library/dom@10.4.2) + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@types/react': + specifier: ^19.0.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.3.0(@types/react@19.3.0) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.3.1 + react-dom: + specifier: ^19.0.0 + version: 19.3.1(react@19.3.1) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(jsdom@26.1.0) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.13': + resolution: {integrity: sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@dnd-kit/abstract@0.5.3': + resolution: {integrity: sha512-KZQ2vftkO9BHupVzlUqB5d7JETL7ITlvslx3hrj4aObwjliAzuzV/n5wCpQllNd0Hxvg7FaQtLekJ8dplqKwbA==} + + '@dnd-kit/collision@0.5.3': + resolution: {integrity: sha512-DBo8czkI72xnsHRWOMTn/GAyYgSWy9bmde85mrZ7ShqLhcbIbcu4PVJ3ZU9MBikePCSAVOoUTZE2gS40jogrEw==} + + '@dnd-kit/dom@0.5.3': + resolution: {integrity: sha512-JJe+Qu/JmofGzHes3np91sQYwp5spvLcknjRkq+VO59JTqSnQNPXlhHXavRXTwQFabaaRyRzXyH/faAvubCNGw==} + + '@dnd-kit/geometry@0.5.3': + resolution: {integrity: sha512-U+zscNjoHfJ1xUV+XDGF89jPCoILLivWSb/U9mfM7h83f6o3X9AS9uIwG46bXUpCqPvlI/9JcOlyga5uvfyjgw==} + + '@dnd-kit/helpers@0.5.3': + resolution: {integrity: sha512-fErbbjwKMJEdr7e+AMsXaZ6aQY4xPYBMK+nqybB34tE6/lQDLetZHsgLDKNVePsYIZCq/7uWMtlFjqysscWg5A==} + + '@dnd-kit/react@0.5.3': + resolution: {integrity: sha512-BGn6bakgcRSoKMRQP8cRIGu59e5lGC3/sL0zVWmDMo/9CMPSxgjCcQltDn5TDTwAARnEbuwNEksor4rLtH/yEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.5.3': + resolution: {integrity: sha512-NJTQkBglAT/SXLc+IOLiKwNvsTrWmPG5AO+7Gn0F0n8XyVaf4VCbTCuPuU48sDBfcpg23O0tfGkfZNiZbYTvMw==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@inertiajs/core@2.3.29': + resolution: {integrity: sha512-Cx3zibnKdsmEnXpsWhwmE9i4k5NFtK+4gtceL3Kb6RwYC1J0wi5y/EesNMkpdJLsAT2h4ugUvu9Ed9T1EUl0/A==} + + '@inertiajs/react@2.3.29': + resolution: {integrity: sha512-09SWBPxv8UrJdOWtQrEp1ro6jv1qJ8yOxH8eL+na9K/wbh7k9R0ALvAO3ofY52Cwuh7HMuSPgtRzk/q8E1k0Fg==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + + '@internationalized/number@3.6.9': + resolution: {integrity: sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g==} + + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21': + resolution: {tarball: https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + '@inertiajs/react': ^2.0.0 || ^3.0.0 + '@javaabu/onu': ^0.2.1 + '@testing-library/react': ^16.0.0 + react: ^18.3.0 || ^19.0.0 + react-dom: ^18.3.0 || ^19.0.0 + peerDependenciesMeta: + '@inertiajs/react': + optional: true + '@javaabu/onu': + optional: true + '@testing-library/react': + optional: true + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba': + resolution: {tarball: https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + vue: ^3.3.0 + peerDependenciesMeta: + react: + optional: true + vue: + optional: true + + '@javaabu/onu@0.2.1': + resolution: {integrity: sha512-GE4takAQwNQqD9ZmQooH7KMBrLUoiR4UzJsDCGr6cLYigoLF22tjRD3CzrdVGVl+SAhz2jjwOozxzyvAyTNmxA==} + peerDependencies: + '@deck.gl/aggregation-layers': ^9.4.0 + '@deck.gl/core': ^9.4.0 + '@deck.gl/google-maps': ^9.4.0 + '@deck.gl/layers': ^9.4.0 + '@fontsource-variable/geist': ^5.3.0 + '@fontsource-variable/inter': ^5.3.0 + '@fontsource-variable/jetbrains-mono': ^5.3.0 + '@fontsource-variable/noto-sans-thaana': ^5.3.0 + '@fontsource-variable/source-serif-4': ^5.3.0 + '@fortawesome/fontawesome-svg-core': ^7.3.1 + '@fortawesome/react-fontawesome': ^3.5.0 + '@fullcalendar/core': ^6.1.21 + '@fullcalendar/daygrid': ^6.1.21 + '@fullcalendar/interaction': ^6.1.21 + '@fullcalendar/list': ^6.1.21 + '@fullcalendar/react': ^6.1.21 + '@fullcalendar/timegrid': ^6.1.21 + '@googlemaps/markerclusterer': ^2.6.2 + '@iconify/react': ^6.0.2 + '@tanstack/react-query': ^5.104.1 + '@tiptap/core': ^3.31.4 + '@tiptap/extension-image': ^3.31.4 + '@tiptap/extension-placeholder': ^3.31.4 + '@tiptap/extension-table': ^3.31.4 + '@tiptap/extension-text-align': ^3.31.4 + '@tiptap/pm': ^3.31.4 + '@tiptap/react': ^3.31.4 + '@tiptap/starter-kit': ^3.31.4 + '@tiptap/static-renderer': ^3.31.4 + '@tiptap/suggestion': ^3.31.4 + '@turf/turf': ^7.4.0 + '@vis.gl/react-google-maps': ^1.10.1 + barcode-detector: ^3.2.2 + media-chrome: ^4.19.3 + react: ^19.0.0 + react-dom: ^19.0.0 + react-easy-crop: ^6.2.4 + react-hook-form: ^7.89.0 + recharts: ^3.10.1 + shiki: ^4.5.0 + signature_pad: ^5.1.4 + tailwindcss: ^4.0.0 + terra-draw: ^1.37.0 + terra-draw-google-maps-adapter: ^1.7.0 + write-excel-file: ^4.1.1 + yet-another-react-lightbox: ^3.32.2 + peerDependenciesMeta: + '@deck.gl/aggregation-layers': + optional: true + '@deck.gl/core': + optional: true + '@deck.gl/google-maps': + optional: true + '@deck.gl/layers': + optional: true + '@fontsource-variable/geist': + optional: true + '@fontsource-variable/inter': + optional: true + '@fontsource-variable/jetbrains-mono': + optional: true + '@fontsource-variable/noto-sans-thaana': + optional: true + '@fontsource-variable/source-serif-4': + optional: true + '@fortawesome/fontawesome-svg-core': + optional: true + '@fortawesome/react-fontawesome': + optional: true + '@fullcalendar/core': + optional: true + '@fullcalendar/daygrid': + optional: true + '@fullcalendar/interaction': + optional: true + '@fullcalendar/list': + optional: true + '@fullcalendar/react': + optional: true + '@fullcalendar/timegrid': + optional: true + '@googlemaps/markerclusterer': + optional: true + '@iconify/react': + optional: true + '@tiptap/core': + optional: true + '@tiptap/extension-image': + optional: true + '@tiptap/extension-placeholder': + optional: true + '@tiptap/extension-table': + optional: true + '@tiptap/extension-text-align': + optional: true + '@tiptap/pm': + optional: true + '@tiptap/react': + optional: true + '@tiptap/starter-kit': + optional: true + '@tiptap/static-renderer': + optional: true + '@tiptap/suggestion': + optional: true + '@turf/turf': + optional: true + '@vis.gl/react-google-maps': + optional: true + barcode-detector: + optional: true + media-chrome: + optional: true + react-easy-crop: + optional: true + recharts: + optional: true + shiki: + optional: true + signature_pad: + optional: true + terra-draw: + optional: true + terra-draw-google-maps-adapter: + optional: true + write-excel-file: + optional: true + yet-another-react-lightbox: + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@preact/signals-core@1.14.5': + resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==} + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.2.0': + resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} + + '@radix-ui/react-accessible-icon@1.2.0': + resolution: {integrity: sha512-BB4+HHkFl3HipEAhm/mLhrpXV+2kRX7MvY4AaImUcQVeWQt5KhV1JEByNs8NGfJOcr95RcQGijt9+OrCo68xGA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.3.1': + resolution: {integrity: sha512-lCZ6sbGS/7WO0j84yx4srIgOf0MRJ/sZ13RonADXxvKBym8xrxNCq5f6DibGcr38sIlwPk8/52uYdXeOPsbEDw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.2.1': + resolution: {integrity: sha512-MWC4N7RY+qsbNrFNo7yQ9ZbdoZEW+B5DtpWxJfUnVCqcQxxqqg3qis3RMPTDbCNaTIdHJSn5XDkl9ZdVWXknLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.17': + resolution: {integrity: sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.2.0': + resolution: {integrity: sha512-gHEU+Lx8DlT/FYIjZQvvBJrFSwUngXTjh7FeL9QvJaNALDLez6pgDj2axCUihBjtPBy82LN0gNb25+sTcSXNmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.3.1': + resolution: {integrity: sha512-ssZOkfQNkFpYVmCPQlr978xSaU7moSrGXdwg4j2Xs2WlGl/1KsSZtfpK+WnLnx0Rwll1BAh6oUrAy/uR31SBvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.4.1': + resolution: {integrity: sha512-KWtwhh+98ML3seZ9vO9yRY2OwaQG90dCiDAxjpSNY2AaZ3Hik3B0dsk6m0ILywo/no3A9/SozadyPMNweVT8yw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.2.1': + resolution: {integrity: sha512-fSzLb/qy1wuZSxQBcxpW0hNXYHDMWiIXDGyJGdA4+FSLknOWZsNRrTCUavxb4PI8b8lvHo/CysO6NVi5zLpo5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.2.0': + resolution: {integrity: sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.4.1': + resolution: {integrity: sha512-Pddn3Z8lio3/ChxyXaEP3jHTTo1LTHCWwF8+mG1chQlWijP+ttVHBKwezrQTiV6Mj670ojvumqvuGh+mdv33iQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.3.0': + resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.3.1': + resolution: {integrity: sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.2.0': + resolution: {integrity: sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.2.1': + resolution: {integrity: sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.2.1': + resolution: {integrity: sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.2.0': + resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.3.1': + resolution: {integrity: sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.3.0': + resolution: {integrity: sha512-KnmrCM27m7Jo1R8S6cFDPrEeq20Jfn5dqde0SZrJr2lVXlj6/QZSUCyFoYh1d8ti0uUrMNK5Lx6dUDuINT+f4Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.2.1': + resolution: {integrity: sha512-4QooHxgmpCKRjj1Pft+hETKidYcrbPKcVwMGtk2Oa5DcWY2IH1fHuQt9LG6q5hUQBBV2MhQxSvtx6B/C1xfiTw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.17': + resolution: {integrity: sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.2.1': + resolution: {integrity: sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.2.1': + resolution: {integrity: sha512-bRTYtS9JITV2dUYqYZb7+N5SooQBkZrNRejfmyrSKe3Y57aXhKgx//kBUP0kY7pEpsWR/U1AHsAJRzjwCrK3PA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.4.1': + resolution: {integrity: sha512-XPPZjkeyCSMNNvt0FyDv/PUauG7Ow4GwHQ/XZSwW36F/TS7rR4HFTsXH8ewLnXTOLn3x96/pmTPNQIVoTX/86g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.2.1': + resolution: {integrity: sha512-KtHdcgnaAK6rohQ/MMJoGYcbgSRJsIuGBdHrFeTB1/9SaKlDs14oSOniW6BL6M43HqvgCO3u4WuVdFLkuc6sqw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.2.1': + resolution: {integrity: sha512-vu4+ZoJAaFjdI7l1lDSZRs+4dEv83Jmu7lyzajPrAeQzX74y84n0rUNWbWSgN9XB5NE8IY86Z02kGH6oRrzR+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.3.1': + resolution: {integrity: sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.4.0': + resolution: {integrity: sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.2.0': + resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.11': + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.2.0': + resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.2.1': + resolution: {integrity: sha512-yQBiM6J3YM8Ums1orkEFpbznK47geKxa0igikzcaS1xHgdba3DdAQBsGbCTt5BVfR8zhEvgxQ0poGcINaD3lvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.5.1': + resolution: {integrity: sha512-QbyVT1ddnjqB9H+CUYKkNWdU9fIK3QHCYtjf7mS6ekSqiuXkWtWn5UZW9mhA8qtl3vrVyIsmR9gePstAtKrqkw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.2.1': + resolution: {integrity: sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.4.0': + resolution: {integrity: sha512-CFrLqHNejWq5FhKAcj9V/WPGRsol76HBQto81NdOBVFEOqIEkJkKIJt0Z824A/9hHLCtoiR/Kw3euRgzXP2pxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.4.1': + resolution: {integrity: sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.2.0': + resolution: {integrity: sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.6.1': + resolution: {integrity: sha512-IRNRR90moPqxfwCJ0XbMJIxo70W0/1ZExT/sNCxEHMaPx0SF9HoQrcPEC75+H28cFKa0b+KH63anfLYJmwKacw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.5.0': + resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.4.1': + resolution: {integrity: sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.2.1': + resolution: {integrity: sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.3.1': + resolution: {integrity: sha512-iQvL0J7XK4HtJNGkkWMyYrboNseI7vtotFeUkIno8YNxVA9oM7B6//fh0Mc6TSKI96+zioLWi98lQLso88yJmQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.2.1': + resolution: {integrity: sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.2.1': + resolution: {integrity: sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.2.1': + resolution: {integrity: sha512-Cm8m7JTEeUdbfA6T9xgyu8HfOpyvSx8pB/wj0DlEuWaJe9FilIs4Az9wci/lU9RbthV/UkltszMw5WnnD+m/Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.4.1': + resolution: {integrity: sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.6': + resolution: {integrity: sha512-9cRullnQRv+kqnRmFlmh0WC3kAPuGmHiQ9VwsXdrYk//sBhgCKEttcpPEPgIQbyx6TGUmooVEHYTyohwz/lSJg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.5': + resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.13': + resolution: {integrity: sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@react-types/shared@3.37.0': + resolution: {integrity: sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@rollup/rollup-android-arm-eabi@4.67.5': + resolution: {integrity: sha512-FdWPeulkH+FH+rBzyKBD59tuVN4tRKvmVilSJm0OcnoYsQTpd9UhD78OaPcsYqo2fX6hCwAG0YIjrDLoQ3pR3g==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.67.5': + resolution: {integrity: sha512-bmkDuBGnF+Ji8atWlXcNjxIC52ShCbdXPbW7Nz0lRoCFi1lrvsO0XobJY36kTj7duc1vOmGJeQ9vtVuGjnzihA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-android-x64@4.67.5': + resolution: {integrity: sha512-6KvVfnqPTqmbQ5YobMl9HYiSymAsCt8HlNeriDshwJQcveXwT+vIMW6wGL1RcJbXH9+r3BAJvjab5+rIgtSEqA==} + cpu: [x64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.67.5': + resolution: {integrity: sha512-SNamgdrY69oPdBrQV9fKGo6PSmKcVhyn0D0jnDmcqgRJvrxQGGBhtekOHTUJ7TCgkW0nE8XMH9v2CwXmjNIzVA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.67.5': + resolution: {integrity: sha512-HNdl34thiJtHDSglogjmAlQ296L8cc8zA7ODH65nnmY7E+jPP55WEbh8J5583rWFLMjxyYywxZuwPOwRvEi5tA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.67.5': + resolution: {integrity: sha512-WvZYtANNswfHzSNHaJAoG6Ipfc4SyZIDYuIjFdHv07xXxhHl+Xx8o+bNDWsxpOADFT2mKXcXVrfOG4OlWTLo8Q==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.67.5': + resolution: {integrity: sha512-EUU+Wgf9NyHekceC+DxAdm42f6bq2ezH/7DEb/JnCb8SAUhG2xph5BiVukxplWal2m5pP8CzJZE+FLwRopywjQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.67.5': + resolution: {integrity: sha512-dgU1CvUuE2IkaJBAeuAbxpDdMHAdZBT5oth5u22DtuoMg4gXXYr2+Sm30RigPJG1F2VEoiGJUziGLuIh9+jPxA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.67.5': + resolution: {integrity: sha512-rdgJcJig2VngYlcTCRQw2BA8kLWM6QwDFe9uLFo81lya0jPC1r8wZu92sAvTUIv48GWlkRVi6/fpsa5CoFSKoQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.67.5': + resolution: {integrity: sha512-+jtCiQnrEFHWAUAcUpP7wppb1p6HKOgBP3mXx75cEYpDF2Y5MEqBky2HNz+osFuVx5uX44gnMTmbe5RJauzn6A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.67.5': + resolution: {integrity: sha512-heflX7ENhL8eHmLcUxDEHCSK8CKLyPprysb2JGoSTh5Ip7nHrzIRc+2USyxul3nxi98H8pGxJIEDEpURlS1X5A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.67.5': + resolution: {integrity: sha512-rRtNU0zBfRHkJ8pscHN9qRtUYLq8Rao4EX7aVWvKcFmJUANkCCXFRU7ovNBEMhpEONyUEB/3DRVDXwDDguSfHA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.67.5': + resolution: {integrity: sha512-/6auyi9yj8MD/Q+8P+f2ay57jQ7a0j7Yp1JK86pecH6ZnRViQZ5GOjoRSjGArFeTSHFznRbM2QPQcBgDPyNMfQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.67.5': + resolution: {integrity: sha512-Hsc0vlt+vPvqzUkHKffWKf3YE/yexrY+yeFf7IsUvDC6PV7YGCsA/gJvPa+WJdgxGJnwuoO1HmLkbahaLKabYQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.67.5': + resolution: {integrity: sha512-Jx5TMMSPJwHY7F/JwPdS6nuBwny2ECRK+EsAJV5HZrfiddeSS6ZrohkLkFZm3rjqBUuQ4FPX5gGMtakUuQO6fQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.67.5': + resolution: {integrity: sha512-fGWcq2lo/XCqAkBLOokg66D/z+MUouryB8i+QdU9RR6O5knG7rucc0WFPqWYL1Wk/wuiJouDfmyBxmZKRM9bag==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.67.5': + resolution: {integrity: sha512-JwO/Ys3jybc0UW3V5XO2CMnp0RhvoczDDQTgAEE1IctT+HRY3W5parIIWLUpmAK4gHSeeYJofm2iQPFA62K6ag==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.67.5': + resolution: {integrity: sha512-vVD4iqel6J6A1xAJlb4mxEO9i1wOJ4/SkcKoDKAuK/wqEBPOA7/P5dDyZvNx46ocHwOUwzo3WjhTdKqET9kEAg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.67.5': + resolution: {integrity: sha512-HmEfNh5Cdd/NHxgqwMzKnjlvicQQA+D+w+vQXDJSV0x/aHiQSjVwh1Th5RoA7QsMieIaxibt/5iJq68BraTSUg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.67.5': + resolution: {integrity: sha512-NfmALpbe009nYzZ2QqlSW6nzbGddXpqk3nwualrIr+4QflQCTGMzt3QKK20yARiAnSV9fJdM5WNwIYUf31x3uA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.67.5': + resolution: {integrity: sha512-ngcE7Udpy/iJ3lexVhYcbpUV2OJIakRGus3snc6K52BnAAJetDBG1Ku5Eh3CECtnW3mpBYWg5VCD4tTaOXKOVQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.67.5': + resolution: {integrity: sha512-DZ01A2EdTYX8xVeGoGiWkJBRWDh+/BOflwGPVsBay43PDxcq8ez0xuovZnPnk1xjE3CdI0pdZXxsJp/Xjmf7uQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.67.5': + resolution: {integrity: sha512-nJKiMKDO140DUCEcQZl+f4HiGPMBxrW2webjuWJ2E3qPeoruHf2zByml4TP52lA1zDi6L4I92D/5stxeA/6pig==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.67.5': + resolution: {integrity: sha512-ykLkBCf+Kls24QmqM3IRf1T4sGMh0rXPyymtj+hXlWKPJnXbXraHqUUsKtOIxqLasjT1i7DhfQGAaZYeiHbSOw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.67.5': + resolution: {integrity: sha512-NiFPUFw8cHhkmBlL1k1eaz+5AXXm89rztj+DQdDuQ7VGPM0+ADFhezQ2KcBMF50UEFzGUr0+DVjVND743XwKWQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.67.5': + resolution: {integrity: sha512-Khj/vrKCpdbiAWYbJAeSy4D5iep+qwW/86lNoKvdleJYGxgbRE7h87+5d5T9D7gt8NcqhNFpreRgIg8T4bLzKQ==} + cpu: [x64] + os: [win32] + + '@sindresorhus/slugify@3.0.1': + resolution: {integrity: sha512-N1oPvN6sytRKZCskv0L+vSyvnUYi9JplPXZ90lT6EBsijjHrUdoXttb0Lna6pw1TWMe8vlajQnLDtU9KYHqtDg==} + engines: {node: '>=20'} + + '@sindresorhus/transliterate@2.3.1': + resolution: {integrity: sha512-gVaaGtKYMYAMmI8buULVH3A2TXVJ98QiwGwI7ddrWGuGidGC2uRt4FHs22+8iROJ0QTzju9CuMjlVsrvpqsdhA==} + engines: {node: '>=20'} + + '@swc/helpers@0.5.24': + resolution: {integrity: sha512-6tcXo4kA8HgGO7AVmawB57RYi/piBPYVHVcO6dXjX+SZw8GoHmi262sp4v2Ha2kNP9Cx84qei27yT+fjS1lxgg==} + + '@tanstack/query-core@5.106.1': + resolution: {integrity: sha512-erYv/UACddcdgjOL2ixmFdK6tO/AVBeUcTCOtX2N51yZpDQ2U7KJ+xgB/bCNfsF3uoCDTqGHEEbDiFsIO9YUmQ==} + + '@tanstack/react-query@5.106.1': + resolution: {integrity: sha512-ORDvJG/DSfmlFL9lQ8GDkE0VjtQbLeRDY7BL8xjKkQFGLfjFGfNWNuteWgPqA2gPJgLLuWfQ7IX0FlsHCJriyg==} + peerDependencies: + '@types/react': ^18 || ^19 + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@9.3.1': + resolution: {integrity: sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug==} + engines: {node: '>=20'} + peerDependencies: + react: '>=18' + + '@tanstack/react-virtual@3.14.26': + resolution: {integrity: sha512-HUDlfsrYH6ko5xH56L62Mcl/uzT+AGVNMHFuDNqDJMdZWc1AQa2oeVKNUvZ/RWBZQ4TYtyQerFrNvLnp39h65A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + + '@tanstack/table-core@9.3.1': + resolution: {integrity: sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ==} + engines: {node: '>=20'} + + '@tanstack/virtual-core@3.19.11': + resolution: {integrity: sha512-/5iHb0RHBvj8Di59FDs4ESNm45jls33aZAi4zccVXZjc/6ZvwQP6nzEYeT6weXjTf9E6/vrp2JhYWRyO+ZP13A==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/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.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.27': + resolution: {integrity: sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.21.0: + resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + 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 + + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + input-otp@1.5.0: + resolution: {integrity: sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + laravel-precognition@1.0.2: + resolution: {integrity: sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg==} + + libphonenumber-js@1.13.15: + resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lucide-react@1.69.0: + resolution: {integrity: sha512-UTFECm4yEgkgMN0kfLuxWcmlk+4u02Jrqehgk3ZXRx/Ud68OpP7hjkp67bvkBHmIpMaZCIveF1QDYeupO7XmYQ==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + 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==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + 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} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.17.1: + resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==} + engines: {node: '>=0.6'} + + radix-ui@1.8.1: + resolution: {integrity: sha512-HPfpVGizO9xANlsFSauruLhRe/3+iOt3vPX0MXYle/UQi3gorQrSYtBcdiv0BZYewcl2rvL59qrIuLFMGAmUrw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + react-aria-components@1.22.1: + resolution: {integrity: sha512-UTHaYHOaM71bOCjGPF50DcLywlrwAodE63IP64LB4kO+OizatQEL8TesVEOM8+TkBeSgHoflJ45388IlJfa7aw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-aria@3.53.1: + resolution: {integrity: sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-day-picker@10.0.2: + resolution: {integrity: sha512-QetiKtZWE9Uaw/pM/SYNv6WPYOTRnSJ2puL0GgwkmUFMIwiY0dtjH1W4B7DOVXVNQM0ZOClGpH/heOfBSzWeiA==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react-hook-form@7.91.0: + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-phone-number-input@3.4.18: + resolution: {integrity: sha512-EmPhJFSxQB7CTmLAlQfGAQQPF+WmJECU08gjyUt50zd2iESb4ETsm6z+BRfUEjH+TXct4EWvJvub+OztJxb/Cg==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-stately@3.51.0: + resolution: {integrity: sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + rollup@4.67.5: + resolution: {integrity: sha512-3Ve5VH1hacCVutdpbmu/htg4kz+JCOIWgy4Q3hjHPYGHeHeIElNHJ0LcJ3rCfIsd5G7qOl5k5mwpzGMj9kdHPA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonner@2.0.8: + resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + + tailwindcss@4.3.4: + resolution: {integrity: sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.7: + resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + 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 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + 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@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + 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@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.22.1: + resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: 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.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.13': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@date-fns/tz@1.5.0': {} + + '@dnd-kit/abstract@0.5.3': + dependencies: + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/collision@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/dom@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/collision': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/geometry@0.5.3': + dependencies: + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/helpers@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/react@0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/state': 0.5.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + tslib: 2.9.1 + + '@dnd-kit/state@0.5.3': + dependencies: + '@preact/signals-core': 1.14.5 + tslib: 2.9.1 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@floating-ui/utils@0.2.12': {} + + '@inertiajs/core@2.3.29': + dependencies: + '@types/lodash-es': 4.17.12 + axios: 1.21.0 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + qs: 6.17.1 + transitivePeerDependencies: + - debug + - supports-color + + '@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@inertiajs/core': 2.3.29 + '@types/lodash-es': 4.17.12 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - debug + - supports-color + + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/number@3.6.9': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/string@3.2.10': + dependencies: + '@swc/helpers': 0.5.24 + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@javaabu/i18n': https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@inertiajs/react': 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/onu': 0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4) + '@testing-library/react': 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - vue + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + + '@javaabu/onu@0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4)': + dependencies: + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/helpers': 0.5.3 + '@dnd-kit/react': 0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@internationalized/date': 3.12.4 + '@sindresorhus/slugify': 3.0.1 + '@tanstack/react-query': 5.106.1(@types/react@19.3.0)(react@19.3.1) + '@tanstack/react-table': 9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/react-virtual': 3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + date-fns: 4.4.0 + embla-carousel-react: 8.6.0(react@19.3.1) + input-otp: 1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + lucide-react: 1.69.0(@types/react@19.3.0)(react@19.3.1) + radix-ui: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-aria-components: 1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-day-picker: 10.0.2(@types/react@19.3.0)(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.1) + react-phone-number-input: 3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + sonner: 2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + tailwind-merge: 3.7.0 + tailwindcss: 4.3.4 + tw-animate-css: 1.4.0 + uqr: 0.1.3 + vaul: 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@preact/signals-core@1.14.5': {} + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.2.0': {} + + '@radix-ui/react-accessible-icon@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-accordion@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-alert-dialog@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-arrow@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-aspect-ratio@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-avatar@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-checkbox@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collapsible@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collection@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-context-menu@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-context@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dialog@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-direction@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dismissable-layer@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-dropdown-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-form@0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-hover-card@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-label@2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menubar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-navigation-menu@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-one-time-password-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-password-toggle-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popover@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popper@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-portal@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-presence@1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-progress@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-radio-group@1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-roving-focus@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-scroll-area@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-select@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-separator@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slider@1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slot@1.5.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-switch@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tabs@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toast@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle-group@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toolbar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tooltip@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-escape-keydown@1.1.6(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-previous@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-size@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-visually-hidden@1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/rect@1.1.3': {} + + '@react-types/shared@3.37.0(react@19.3.1)': + dependencies: + react: 19.3.1 + + '@rollup/rollup-android-arm-eabi@4.67.5': + optional: true + + '@rollup/rollup-android-arm64@4.67.5': + optional: true + + '@rollup/rollup-android-x64@4.67.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.67.5': + optional: true + + '@rollup/rollup-darwin-x64@4.67.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.67.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.67.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.67.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.67.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.67.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.67.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.67.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.67.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.67.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.67.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.67.5': + optional: true + + '@sindresorhus/slugify@3.0.1': + dependencies: + '@sindresorhus/transliterate': 2.3.1 + escape-string-regexp: 5.0.0 + + '@sindresorhus/transliterate@2.3.1': {} + + '@swc/helpers@0.5.24': {} + + '@tanstack/query-core@5.106.1': {} + + '@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@tanstack/query-core': 5.106.1 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@tanstack/react-store@0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/store': 0.11.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + '@tanstack/react-table@9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/react-store': 0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/table-core': 9.3.1 + react: 19.3.1 + transitivePeerDependencies: + - react-dom + + '@tanstack/react-virtual@3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/virtual-core': 3.19.11 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@tanstack/store@0.11.2': {} + + '@tanstack/table-core@9.3.1': + dependencies: + '@tanstack/store': 0.11.2 + + '@tanstack/virtual-core@3.19.11': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.13 + '@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.10.0(@testing-library/dom@10.4.2)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + 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.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.13 + '@testing-library/dom': 10.4.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@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.10': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.27 + + '@types/lodash@4.17.27': {} + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.7)': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.7 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.9.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.21.0: + dependencies: + follow-redirects: 1.16.2 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + classnames@2.5.1: {} + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + country-flag-icons@1.6.20: {} + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + date-fns@4.4.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + embla-carousel-react@8.6.0(react@19.3.1): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.3.1 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@5.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + follow-redirects@1.16.2: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + input-format@0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + input-otp@1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.22.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + laravel-precognition@1.0.2: + dependencies: + axios: 1.21.0 + lodash-es: 4.18.1 + transitivePeerDependencies: + - debug + - supports-color + + libphonenumber-js@1.13.15: {} + + lodash-es@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lucide-react@1.69.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + nwsapi@2.2.28: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + qs@6.17.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + radix-ui@1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-accessible-icon': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-accordion': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-alert-dialog': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-aspect-ratio': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-avatar': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-checkbox': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context-menu': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dropdown-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-form': 0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-hover-card': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menubar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-navigation-menu': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-one-time-password-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-password-toggle-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popover': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-progress': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-radio-group': 1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-scroll-area': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-select': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slider': 1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-switch': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tabs': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toast': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toolbar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tooltip': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.6(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + react-aria-components@1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + client-only: 0.0.1 + react: 19.3.1 + react-aria: 3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-aria@3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-phone-number-input@3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.20 + input-format: 0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + libphonenumber-js: 1.13.15 + prop-types: 15.8.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.3.1) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.3.1) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + react-stately@3.51.0(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) + + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + get-nonce: 1.0.1 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react@19.3.1: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + rollup@4.67.5: + dependencies: + '@types/estree': 1.0.10 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.67.5 + '@rollup/rollup-android-arm64': 4.67.5 + '@rollup/rollup-android-x64': 4.67.5 + '@rollup/rollup-darwin-arm64': 4.67.5 + '@rollup/rollup-darwin-x64': 4.67.5 + '@rollup/rollup-freebsd-arm64': 4.67.5 + '@rollup/rollup-freebsd-x64': 4.67.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.67.5 + '@rollup/rollup-linux-arm-musleabihf': 4.67.5 + '@rollup/rollup-linux-arm64-gnu': 4.67.5 + '@rollup/rollup-linux-arm64-musl': 4.67.5 + '@rollup/rollup-linux-loong64-gnu': 4.67.5 + '@rollup/rollup-linux-loong64-musl': 4.67.5 + '@rollup/rollup-linux-ppc64-gnu': 4.67.5 + '@rollup/rollup-linux-ppc64-musl': 4.67.5 + '@rollup/rollup-linux-riscv64-gnu': 4.67.5 + '@rollup/rollup-linux-riscv64-musl': 4.67.5 + '@rollup/rollup-linux-s390x-gnu': 4.67.5 + '@rollup/rollup-linux-x64-gnu': 4.67.5 + '@rollup/rollup-linux-x64-musl': 4.67.5 + '@rollup/rollup-openbsd-x64': 4.67.5 + '@rollup/rollup-openharmony-arm64': 4.67.5 + '@rollup/rollup-win32-arm64-msvc': 4.67.5 + '@rollup/rollup-win32-ia32-msvc': 4.67.5 + '@rollup/rollup-win32-x64-gnu': 4.67.5 + '@rollup/rollup-win32-x64-msvc': 4.67.5 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.28.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + source-map-js@1.2.2: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + symbol-tree@3.2.4: {} + + tailwind-merge@3.7.0: {} + + tailwindcss@4.3.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tslib@2.9.1: {} + + tw-animate-css@1.4.0: {} + + typescript@5.9.3: {} + + uqr@0.1.3: {} + + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sync-external-store@1.7.0(react@19.3.1): + dependencies: + react: 19.3.1 + + vaul@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.7 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.7: + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.32 + rollup: 4.67.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.7(jsdom@26.1.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.7) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.7 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.22.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..fd568e2 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,12 @@ +{{-- + javaabu/mobile-verification guidelines for AI code assistants + + Maintained by: Javaabu (https://javaabu.com) +--}} + +## javaabu/mobile-verification + +- This project logs users in, signs them up and verifies their mobile numbers with SMS codes from `javaabu/mobile-verification`, on top of `javaabu/auth` user types. Mobile login is the `Javaabu\MobileVerification\Auth\MobileLoginMethod` login method of a user type; `Route::authRoutes()` registers its routes (`login.mobile`, `register.mobile`, with the type's prefix). +- Don't write OTP or mobile login controllers by hand, compare codes yourself or call `Auth::login()`: use the package's flows, `SendVerificationCode` / `VerifyVerificationCode`, and javaabu/auth's `StartAuthenticatedSession` (two-factor still applies). +- Always activate the `javaabu-mobile-verification` skill when turning on mobile login or registration for a user type, changing users' mobile numbers, sending or checking verification codes, protecting pages with `mobile.verified`, customising the SMS, the registration form or the pages, or issuing API tokens for a number and code. +- Numbers belong to users through `InteractsWithMobileNumbers` (`$user->phone`, `$user->formatted_mobile_number`); change them with `updateMobileNumber()` or on the `mobile-number` account page (`Route::mobileNumberRoutes()`). diff --git a/resources/boost/skills/javaabu-mobile-verification/SKILL.md b/resources/boost/skills/javaabu-mobile-verification/SKILL.md new file mode 100644 index 0000000..c54b03b --- /dev/null +++ b/resources/boost/skills/javaabu-mobile-verification/SKILL.md @@ -0,0 +1,101 @@ +--- +name: javaabu-mobile-verification +description: Mobile number login, registration and verification with one-time SMS codes using javaabu/mobile-verification on top of javaabu/auth user types. Use whenever the project depends on javaabu/mobile-verification and the task turns on mobile login or registration for a user type, reads or changes users' mobile numbers, sends or checks verification codes, protects routes with mobile.verified, customises the SMS, the registration form, the pages or the controllers, issues API tokens for a number and code (Sanctum, Passport mobile grant), or adds admin actions on users' numbers. +license: MIT +metadata: + author: Javaabu +--- + +# Mobile numbers with javaabu/mobile-verification + +The package owns the mobile login, registration and verification flows, their pages and the code handling; the app turns them on per javaabu/auth **user type** and protects its own routes. The docs ship with the package: `vendor/javaabu/mobile-verification/llms.txt` lists every page in `vendor/javaabu/mobile-verification/docs/`. + +## Turning on mobile login for a user type + +1. The user model: + +```php +namespace App\Models; + +use Javaabu\Auth\Models\User as BaseUser; +use Javaabu\MobileVerification\Concerns\InteractsWithMobileNumbers; +use Javaabu\MobileVerification\Contracts\HasMobileNumber; + +class User extends BaseUser implements HasMobileNumber +{ + use InteractsWithMobileNumbers; +} +``` + +2. With mobile registration, users have no password: `UserSchema::columns($table, password_required: false, email_required: false)`. + +3. The login method on the type, then the routes: + +```php +// config/javaabu-auth.php +'user' => [ + 'guard' => 'web', + 'route_name' => '', + 'home' => '/dashboard', + 'login_methods' => [\Javaabu\MobileVerification\Auth\MobileLoginMethod::class], +], +``` + +```php +// routes/web.php +Route::authRoutes('user'); // login/mobile, register/mobile (if the type has registration) +Route::mobileNumberRoutes('user'); // settings/mobile-number, route name `mobile-number` + +Route::middleware(['auth.type:user', 'mobile.verified:user'])->group(function () { + Route::get('dashboard', DashboardController::class)->name('dashboard'); +}); +``` + +4. An SMS channel: `composer require dash8x/dhiraagu-sms-notification` and `SMS_DRIVER=dhiraagu` (javaabu/sms-notifications). + +5. `php artisan migrate` (the package loads its `mobile_numbers` migration) and `php artisan permissions:sync`. + +## Reading and changing numbers + +```php +$user->phone; // ?MobileNumber +$user->hasVerifiedMobile(); +$user->formatted_mobile_number; // '+9607771234' +User::findByMobileNumber('7771234'); + +$user->updateMobileNumber('9991234', causer: $admin); // no code; null if another user has it +$user->removeMobileNumber(causer: $admin); +``` + +Users change their own number on the `mobile-number` page, which sends a code to the new number first. The package already adds it to javaabu/auth's account navigation for every type with the route; don't add a link by hand (change it with `UserTypes::get('user')->accountLink(...)` or `withoutAccountLink('mobile-number')`). Custom React account pages pass `account_links` to `AccountLayout`'s `links`. Admins use `Route::mobileNumberActions('user', ['prefix' => 'admin/users', 'middleware' => ['web', 'auth.type:admin']])`. + +## Codes in your own flows + +```php +use Javaabu\MobileVerification\Actions\SendVerificationCode; +use Javaabu\MobileVerification\Actions\VerifyVerificationCode; +use Javaabu\MobileVerification\Enums\Purposes; + +$mobile_number = app(SendVerificationCode::class)($type, Purposes::UPDATE, $country_code, $number, $user); + +// throws a ValidationException on `verification_code`; the right code is used up +app(VerifyVerificationCode::class)($mobile_number, $request->input('verification_code'), $mobile_number->verification_code_id); +``` + +## Customising + +| Change | How | +| --- | --- | +| Registration fields | Extend `Stencil\MobileRegistrationForm`, set `mobile-verification.registration_form` | +| How users are created | Bind `Contracts\CreatesUsersFromMobileNumbers` | +| The SMS | Extend `Notifications\VerificationCodeNotification`, set `notification`; or change the translation | +| Number formats | Bind `Contracts\ValidatesNumberFormat` | +| A step of a flow | Extend the controller, set it in `mobile-verification.controllers` | +| A captcha | `request_code_rules` | +| Two-factor after a mobile login | On by default; `skip_two_factor` turns it off | +| Pages | `vendor:publish --tag=mobile-verification-views`, `php artisan manifest:override mobile-verification Pages/Login.tsx` | + +## References + +- [Common mistakes](references/common-mistakes.md) +- [Testing](references/testing.md) diff --git a/resources/boost/skills/javaabu-mobile-verification/references/common-mistakes.md b/resources/boost/skills/javaabu-mobile-verification/references/common-mistakes.md new file mode 100644 index 0000000..7bd4772 --- /dev/null +++ b/resources/boost/skills/javaabu-mobile-verification/references/common-mistakes.md @@ -0,0 +1,53 @@ +# Common mistakes + +## Writing the flow by hand + +```php +// Wrong: plain codes, no attempts, no lock, skips two-factor +$code = rand(100000, 999999); +session(['otp' => $code]); +if ($request->code == session('otp')) { + Auth::login(User::where('mobile', $request->number)->first()); +} +``` + +Turn on `MobileLoginMethod` for the type. For your own flows, use `SendVerificationCode` and `VerifyVerificationCode`, and finish logins with javaabu/auth's `StartAuthenticatedSession`. + +## Checking a code in a validation rule + +A wrong code counts as an attempt and can lock the number; a rule that runs twice, or on a request that fails for another field, burns attempts. Validate the fields, then call `VerifyVerificationCode`. + +## Assuming one kind of user + +```php +// Wrong: admins and users can share a number, and the guard may not be `web` +MobileNumber::where('number', $number)->first()->user; +Auth::guard('web')->login($user); +``` + +```php +use Javaabu\MobileVerification\MobileVerification; + +$type = UserTypes::get('admin'); +$user = MobileVerification::userFor($type, MobileVerification::find($type, $country_code, $number)); +``` + +## Hard-coding routes + +`route('login.mobile')` is the default type's; other types have a prefix (`admin.login.mobile`). Use `$type->route('login.mobile')` and `$type->route('mobile-number')`. + +## Registering routes before the login method + +`MobileVerification::enableFor()` (or `UserTypes::get(...)->loginMethod(...)`) must run before `Route::authRoutes()`. Config (`login_methods`) always does. + +## Required password or email columns + +Mobile registration creates users without a password, and the default form doesn't require an email. Make those columns nullable, or change the form and the `CreatesUsersFromMobileNumbers` action. + +## Forgetting the account status + +New users get javaabu/auth's default status, `pending`. Approve them in your `CreatesUsersFromMobileNumbers` action if they may use the app at once. + +## Physical CSS and reversed numbers + +Use `ms-`/`me-`/`text-start`, and keep numbers `dir="ltr"` (wrap them in `` inside Dhivehi text) so `+960` stays in front. diff --git a/resources/boost/skills/javaabu-mobile-verification/references/testing.md b/resources/boost/skills/javaabu-mobile-verification/references/testing.md new file mode 100644 index 0000000..17ccf71 --- /dev/null +++ b/resources/boost/skills/javaabu-mobile-verification/references/testing.md @@ -0,0 +1,58 @@ +# Testing + +Fake notifications and read the code from the SMS: + +```php +use Illuminate\Support\Facades\Notification; +use Javaabu\MobileVerification\Models\MobileNumber; +use Javaabu\MobileVerification\Notifications\VerificationCodeNotification; +use PHPUnit\Framework\Attributes\Test; + +#[Test] +public function users_log_in_with_their_mobile_number(): void +{ + Notification::fake(); + + $user = User::factory()->create(); + $user->updateMobileNumber('7771234'); + + $this->post('/login/mobile', ['number' => '7771234']) + ->assertRedirect('/login/mobile/verify'); + + $code = null; + + Notification::assertSentTo( + MobileNumber::query()->hasNumber('7771234')->sole(), + VerificationCodeNotification::class, + function (VerificationCodeNotification $notification) use (&$code) { + $code = $notification->verification_code; + + return true; + }, + ); + + $this->post('/login/mobile/verify', ['verification_code' => $code]) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); +} +``` + +Or bind a fixed code: + +```php +$this->app->bind(\Javaabu\MobileVerification\Contracts\GeneratesVerificationCodes::class, fn () => new class implements \Javaabu\MobileVerification\Contracts\GeneratesVerificationCodes { + public function generate(): string { return '123456'; } +}); +``` + +Factories: + +```php +MobileNumber::factory()->forUser($user)->create(['number' => '7771234']); +MobileNumber::factory()->forUser($user)->locked()->create(); +``` + +Time: codes expire after `verification_code_validity` minutes and can be resent after `resend_interval` seconds; use `$this->travel(31)->seconds()` and `$this->freezeSecond()` for exact counts. + +Test with Thaana for user-facing text (`$this->app->setLocale('dv')`), and for every user type that has mobile login. diff --git a/resources/js/Pages/Login.tsx b/resources/js/Pages/Login.tsx new file mode 100644 index 0000000..4552528 --- /dev/null +++ b/resources/js/Pages/Login.tsx @@ -0,0 +1,36 @@ +import { Checkbox, Form, LinkButton, Submit } from "@javaabu/forms"; +import { MobileCard } from "../components/MobileCard"; +import { NumberInput } from "../components/NumberInput"; +import { StatusAlert } from "../components/StatusAlert"; +import { t } from "../lib/strings"; +import type { MobileLoginPageProps } from "../types"; + +/** mobile-verification::Login */ +export default function Login({ urls, strings, status, mobile, can_register }: MobileLoginPageProps) { + return ( + + + +
+ + + {t(strings, "send_code")} + + + {urls.login ? ( + + {t(strings, "back_to_login")} + + ) : null} + + {can_register && urls["register.mobile"] ? ( +

+ {t(strings, "no_account")}{" "} + + {t(strings, "sign_up")} + +

+ ) : null} +
+ ); +} diff --git a/resources/js/Pages/Register.tsx b/resources/js/Pages/Register.tsx new file mode 100644 index 0000000..30a94b1 --- /dev/null +++ b/resources/js/Pages/Register.tsx @@ -0,0 +1,27 @@ +import { Form, Submit } from "@javaabu/forms"; +import { MobileCard } from "../components/MobileCard"; +import { NumberInput } from "../components/NumberInput"; +import { StatusAlert } from "../components/StatusAlert"; +import { t } from "../lib/strings"; +import type { MobileRegisterPageProps } from "../types"; + +/** mobile-verification::Register */ +export default function Register({ urls, strings, status, mobile }: MobileRegisterPageProps) { + return ( + + + +
+ + {t(strings, "send_code")} + + +

+ {t(strings, "have_account")}{" "} + + {t(strings, "log_in")} + +

+
+ ); +} diff --git a/resources/js/Pages/RegisterDetails.tsx b/resources/js/Pages/RegisterDetails.tsx new file mode 100644 index 0000000..7b36e65 --- /dev/null +++ b/resources/js/Pages/RegisterDetails.tsx @@ -0,0 +1,24 @@ +import { StencilForm } from "@javaabu/stencil"; +import { MobileCard } from "../components/MobileCard"; +import { StatusAlert } from "../components/StatusAlert"; +import { t } from "../lib/strings"; +import type { RegisterDetailsPageProps } from "../types"; + +/** mobile-verification::RegisterDetails — the javaabu/stencil registration form */ +export default function RegisterDetails({ form, mobile_number, strings, status }: RegisterDetailsPageProps) { + const [before, after] = t(strings, "register_details_help").split(":number"); + + return ( + + + +

+ {before} + {mobile_number.formatted_number} + {after} +

+ + +
+ ); +} diff --git a/resources/js/Pages/Settings/MobileNumber.tsx b/resources/js/Pages/Settings/MobileNumber.tsx new file mode 100644 index 0000000..664b628 --- /dev/null +++ b/resources/js/Pages/Settings/MobileNumber.tsx @@ -0,0 +1,40 @@ +import { Alert, Card, Form, Submit } from "@javaabu/forms"; +import { AccountLayout } from "@javaabu/auth"; +import { NumberInput } from "../../components/NumberInput"; +import { StatusAlert } from "../../components/StatusAlert"; +import { t } from "../../lib/strings"; +import type { MobileNumberSettingsPageProps } from "../../types"; + +/** mobile-verification::Settings/MobileNumber — verify or change the signed-in user's number */ +export default function MobileNumber({ current_number, mobile, urls, account_links, strings, status }: MobileNumberSettingsPageProps) { + const [before, after] = t(strings, "current_number").split(":number"); + + return ( + + + + +
+

{t(strings, "settings_mobile_help")}

+ + {current_number ? ( +

+ {before} + {current_number.formatted_number} + {after} +

+ ) : ( +
+ {t(strings, "no_number")} +
+ )} + +
+ + {t(strings, "send_code")} + +
+
+
+ ); +} diff --git a/resources/js/Pages/Verify.tsx b/resources/js/Pages/Verify.tsx new file mode 100644 index 0000000..1856a53 --- /dev/null +++ b/resources/js/Pages/Verify.tsx @@ -0,0 +1,76 @@ +import { Button, Form, Submit, Text } from "@javaabu/forms"; +import { AccountLayout } from "@javaabu/auth"; +import type { ReactNode } from "react"; +import { MobileCard } from "../components/MobileCard"; +import { StatusAlert } from "../components/StatusAlert"; +import { t } from "../lib/strings"; +import type { VerifyPageProps } from "../types"; + +/** mobile-verification::Verify — the code page of every flow */ +export default function Verify({ purpose, title, mobile_number, verify_url, resend_url, change_url, mobile, urls, account_links, strings, status }: VerifyPageProps) { + const [before, after] = t(strings, "code_help", { + length: mobile.code_length, + minutes: Math.max(1, Math.ceil(mobile_number.expires_in / 60)), + }).split(":number"); + + const content: ReactNode = ( +
+ + +

+ {before} + {mobile_number.formatted_number} + {after} +

+ +
+ + {t(strings, "verify")} + + +
+
+ +
+ + + {t(strings, "change_number")} + +
+ + {mobile_number.resend_in > 0 ? ( +

+ {t(strings, "resend_in", { seconds: mobile_number.resend_in })} +

+ ) : null} +
+ ); + + if (purpose === "update") { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} diff --git a/resources/js/components/MobileCard.tsx b/resources/js/components/MobileCard.tsx new file mode 100644 index 0000000..7956360 --- /dev/null +++ b/resources/js/components/MobileCard.tsx @@ -0,0 +1,19 @@ +import { Card } from "@javaabu/forms"; +import type { ReactNode } from "react"; + +export interface MobileCardProps { + title: string; + page: string; + children: ReactNode; +} + +/** The card the guest pages sit in, as javaabu/auth's AuthCard */ +export function MobileCard({ title, page, children }: MobileCardProps) { + return ( +
+ +
{children}
+
+
+ ); +} diff --git a/resources/js/components/NumberInput.tsx b/resources/js/components/NumberInput.tsx new file mode 100644 index 0000000..2ce05bf --- /dev/null +++ b/resources/js/components/NumberInput.tsx @@ -0,0 +1,55 @@ +import { Select, Tel } from "@javaabu/forms"; +import { t } from "../lib/strings"; +import type { MobileSettings, MobileStrings } from "../types"; + +export interface NumberInputProps { + mobile: MobileSettings; + strings: MobileStrings; + /** The label of the number; "Mobile number" by default */ + label?: string; +} + +/** The country code (a select when there's a choice) and the number, as the Blade partial */ +export function NumberInput({ mobile, strings, label }: NumberInputProps) { + const codes = Object.entries(mobile.country_codes); + const numberLabel = label ?? t(strings, "mobile_number"); + const help = t(strings, "mobile_number_help"); + + if (codes.length > 1) { + return ( +
+ + {prefix}} + help={help} + /> + + ); +} diff --git a/resources/js/components/StatusAlert.tsx b/resources/js/components/StatusAlert.tsx new file mode 100644 index 0000000..829639f --- /dev/null +++ b/resources/js/components/StatusAlert.tsx @@ -0,0 +1,13 @@ +import { Alert } from "@javaabu/forms"; + +export function StatusAlert({ status }: { status?: string | null }) { + if (!status) { + return null; + } + + return ( +
+ {status} +
+ ); +} diff --git a/resources/js/index.ts b/resources/js/index.ts new file mode 100644 index 0000000..ce52644 --- /dev/null +++ b/resources/js/index.ts @@ -0,0 +1,6 @@ +// What apps import as @javaabu/mobile-verification (through javaabu/manifest) +export { MobileCard, type MobileCardProps } from "./components/MobileCard"; +export { NumberInput, type NumberInputProps } from "./components/NumberInput"; +export { StatusAlert } from "./components/StatusAlert"; +export { t } from "./lib/strings"; +export type * from "./types"; diff --git a/resources/js/lib/strings.ts b/resources/js/lib/strings.ts new file mode 100644 index 0000000..9f1b795 --- /dev/null +++ b/resources/js/lib/strings.ts @@ -0,0 +1,12 @@ +import type { MobileStrings } from "../types"; + +/** A translated string, with :placeholders replaced */ +export function t(strings: MobileStrings, key: string, replace: Record = {}): string { + let text = strings[key] ?? key; + + for (const [name, value] of Object.entries(replace)) { + text = text.replaceAll(`:${name}`, String(value)); + } + + return text; +} diff --git a/resources/js/types.ts b/resources/js/types.ts new file mode 100644 index 0000000..1403366 --- /dev/null +++ b/resources/js/types.ts @@ -0,0 +1,53 @@ +import type { AuthPageProps } from "@javaabu/auth"; +import type { StencilSchema } from "@javaabu/stencil"; + +/** What the pages know about numbers (Javaabu\MobileVerification\Views\ViewRenderer::sharedData) */ +export interface MobileSettings { + /** Country code → shown prefix: `{ "960": "+960" }` */ + country_codes: Record; + default_country_code: string; + number_prefix: string; + code_length: number; +} + +/** The translated strings, keyed as in Javaabu\MobileVerification\Support\Strings (and javaabu/auth's) */ +export type MobileStrings = Record; + +/** Props every page gets from the server: javaabu/auth's, plus the number settings */ +export interface MobilePageProps extends AuthPageProps { + mobile: MobileSettings; + strings: MobileStrings; +} + +export interface MobileLoginPageProps extends MobilePageProps { + can_register: boolean; +} + +export type MobileRegisterPageProps = MobilePageProps; + +export interface PendingNumber { + formatted_number: string; + /** Seconds until the code expires */ + expires_in: number; + /** Seconds before another code can be sent */ + resend_in: number; + remaining_attempts: number; +} + +export interface VerifyPageProps extends MobilePageProps { + purpose: "login" | "register" | "update"; + title: string; + mobile_number: PendingNumber; + verify_url: string; + resend_url: string; + change_url: string; +} + +export interface RegisterDetailsPageProps extends MobilePageProps { + form: StencilSchema; + mobile_number: { formatted_number: string }; +} + +export interface MobileNumberSettingsPageProps extends MobilePageProps { + current_number: { number: string; country_code: string; formatted_number: string } | null; +} diff --git a/resources/views/login.blade.php b/resources/views/login.blade.php new file mode 100644 index 0000000..740c7d0 --- /dev/null +++ b/resources/views/login.blade.php @@ -0,0 +1,28 @@ +@extends($layout) + +@section($layout_section) + +
+ @include('mobile-verification::partials.status') + + + @include('mobile-verification::partials.number-input') + + + + {{ __('Send code') }} + + + + {{ __('Log in with a password instead') }} + + + @if($can_register) +

+ {{ __("Don't have an account?") }} + {{ __('Sign up') }} +

+ @endif +
+
+@endsection diff --git a/resources/views/partials/number-input.blade.php b/resources/views/partials/number-input.blade.php new file mode 100644 index 0000000..b0e893a --- /dev/null +++ b/resources/views/partials/number-input.blade.php @@ -0,0 +1,45 @@ +{{-- The country code (a select when there's a choice) and the number --}} +@php($country_codes = $mobile['country_codes']) +@php($label = $label ?? __('Mobile number')) + +@if(count($country_codes) > 1) +
+ + +
+ + {{ __('We will send you a code by SMS.') }} + +
+
+@else + + + + {{ reset($country_codes) }} + {{ __('We will send you a code by SMS.') }} + +@endif diff --git a/resources/views/partials/status.blade.php b/resources/views/partials/status.blade.php new file mode 100644 index 0000000..1103b31 --- /dev/null +++ b/resources/views/partials/status.blade.php @@ -0,0 +1,5 @@ +@if($status ?? session('status')) + + {{ $status ?? session('status') }} + +@endif diff --git a/resources/views/register-details.blade.php b/resources/views/register-details.blade.php new file mode 100644 index 0000000..cd0ed3e --- /dev/null +++ b/resources/views/register-details.blade.php @@ -0,0 +1,15 @@ +@extends($layout) + +@section($layout_section) + +
+ @include('mobile-verification::partials.status') + +

+ {{ __('Your number :number is verified. Tell us a little about yourself.', ['number' => $mobile_number['formatted_number']]) }} +

+ + {{ $form }} +
+
+@endsection diff --git a/resources/views/register.blade.php b/resources/views/register.blade.php new file mode 100644 index 0000000..e1d2ad0 --- /dev/null +++ b/resources/views/register.blade.php @@ -0,0 +1,20 @@ +@extends($layout) + +@section($layout_section) + +
+ @include('mobile-verification::partials.status') + + + @include('mobile-verification::partials.number-input') + + {{ __('Send code') }} + + +

+ {{ __('Already have an account?') }} + {{ __('Log in') }} +

+
+
+@endsection diff --git a/resources/views/settings/mobile-number.blade.php b/resources/views/settings/mobile-number.blade.php new file mode 100644 index 0000000..88a5a7d --- /dev/null +++ b/resources/views/settings/mobile-number.blade.php @@ -0,0 +1,27 @@ +@extends($layout) + +@section($layout_section) + +
+ @include('mobile-verification::partials.status') + +

{{ __('Your mobile number is used to log in and for SMS notifications.') }}

+ + @if($current_number) +

+ {!! __('Your mobile number is :number.', ['number' => '' . e($current_number['formatted_number']) . '']) !!} +

+ @else + + {{ __('You have not verified a mobile number yet.') }} + + @endif + + + @include('mobile-verification::partials.number-input', ['label' => $current_number ? __('New mobile number') : __('Mobile number')]) + + {{ __('Send code') }} + +
+
+@endsection diff --git a/resources/views/verify.blade.php b/resources/views/verify.blade.php new file mode 100644 index 0000000..a788564 --- /dev/null +++ b/resources/views/verify.blade.php @@ -0,0 +1,48 @@ +@extends($layout) + +@section($layout_section) + +
+ @include('mobile-verification::partials.status') + +

+ {!! __('We sent a :length digit code to :number. It expires in :minutes minutes.', [ + 'length' => e($mobile['code_length']), + 'number' => '' . e($mobile_number['formatted_number']) . '', + 'minutes' => e(max(1, (int) ceil($mobile_number['expires_in'] / 60))), + ]) !!} +

+ + + + + {{ __('Verify') }} + + +
+ + {{ __('Send a new code') }} + + + {{ __('Change number') }} +
+ + @if($mobile_number['resend_in'] > 0) +

+ {{ __('You can ask for a new code in :seconds seconds.', ['seconds' => $mobile_number['resend_in']]) }} +

+ @endif +
+
+@endsection diff --git a/src/Actions/AuthenticateMobileNumber.php b/src/Actions/AuthenticateMobileNumber.php new file mode 100644 index 0000000..ccef7f2 --- /dev/null +++ b/src/Actions/AuthenticateMobileNumber.php @@ -0,0 +1,84 @@ + __('There is no account with this mobile number.')]); + } + + ($this->verify)($mobile_number, $input['verification_code'], $input['verification_code_id'] ?? null); + + $inactive = AccountState::inactiveMessage($user); + + if ($inactive !== null) { + throw ValidationException::withMessages(['number' => $inactive]); + } + + if (! MobileVerification::skipsTwoFactor() && $this->start_session->needsTwoFactorChallenge($type, $user)) { + $this->checkTwoFactor($user, $input['code'] ?? null, $input['recovery_code'] ?? null); + } + + return $user; + } + + protected function checkTwoFactor(TwoFactorAuthenticatable $user, ?string $code, ?string $recovery_code): void + { + if (filled($recovery_code)) { + foreach ($user->recoveryCodes() as $stored) { + if (hash_equals($stored, (string) $recovery_code)) { + $user->replaceRecoveryCode($stored); + + return; + } + } + + throw ValidationException::withMessages(['recovery_code' => __('The provided two-factor recovery code was invalid.')]); + } + + if (blank($code)) { + throw ValidationException::withMessages(['code' => __('Enter the code from your authenticator app.')]); + } + + if (! $this->two_factor->verify((string) $user->twoFactorSecret(), (string) $code)) { + throw ValidationException::withMessages(['code' => __('The provided two-factor authentication code was invalid.')]); + } + } +} diff --git a/src/Actions/CreateUserFromMobileNumber.php b/src/Actions/CreateUserFromMobileNumber.php new file mode 100644 index 0000000..dacfff0 --- /dev/null +++ b/src/Actions/CreateUserFromMobileNumber.php @@ -0,0 +1,28 @@ +newModel(); + + if (isset($input['email']) && is_string($input['email'])) { + $input['email'] = mb_strtolower(trim($input['email'])); + } + + $user->forceFill($input)->save(); + + return $user; + } +} diff --git a/src/Actions/SendVerificationCode.php b/src/Actions/SendVerificationCode.php new file mode 100644 index 0000000..c005120 --- /dev/null +++ b/src/Actions/SendVerificationCode.php @@ -0,0 +1,115 @@ +findMobileNumber($type, $purpose, $country_code, $number, $user, $error_key); + + $this->ensureCanSend($mobile_number, $error_key); + + $code = $mobile_number->generateVerificationCode(); + + $mobile_number->notify($this->notification($code, $purpose, $mobile_number, $user)); + + VerificationCodeSent::dispatch($mobile_number, $purpose); + + return $mobile_number; + } + + protected function findMobileNumber( + UserType $type, + Purposes $purpose, + ?string $country_code, + string $number, + ?Model $user, + string $error_key, + ): MobileNumber { + if ($purpose->needsOwner()) { + $mobile_number = MobileVerification::find($type, $country_code, $number); + + if (! MobileVerification::userFor($type, $mobile_number)) { + throw ValidationException::withMessages([ + $error_key => __('There is no account with this mobile number.'), + ]); + } + + return $mobile_number; + } + + $mobile_number = MobileVerification::findOrCreate($type, $country_code, $number); + + if ($mobile_number->hasOwner() && ! ($user && $mobile_number->user()->is($user))) { + throw ValidationException::withMessages([ + $error_key => __('This mobile number is already registered.'), + ]); + } + + if ($user && $mobile_number->user()->is($user)) { + throw ValidationException::withMessages([ + $error_key => __('This is already your mobile number.'), + ]); + } + + return $mobile_number; + } + + protected function ensureCanSend(MobileNumber $mobile_number, string $error_key): void + { + if ($mobile_number->isLocked()) { + throw ValidationException::withMessages([ + $error_key => static::lockedMessage($mobile_number), + ])->status(429); + } + + if ($mobile_number->wasSentRecently()) { + throw ValidationException::withMessages([ + $error_key => __('A code was sent to this number recently. You can ask for another in :seconds seconds.', [ + 'seconds' => $mobile_number->resendVerificationCodeIn(), + ]), + ])->status(429); + } + } + + protected function notification(string $code, Purposes $purpose, MobileNumber $mobile_number, ?Model $user): VerificationCodeNotification + { + $class = MobileVerification::config('notification', VerificationCodeNotification::class); + $owner = $user ?? $mobile_number->user; + + return new $class($code, $purpose, $owner?->getAttribute('name')); + } + + public static function lockedMessage(MobileNumber $mobile_number): string + { + return __('This number is locked after too many wrong codes. Try again in :minutes minutes.', [ + 'minutes' => max(1, (int) ceil($mobile_number->lockExpiresIn() / 60)), + ]); + } +} diff --git a/src/Actions/VerifyVerificationCode.php b/src/Actions/VerifyVerificationCode.php new file mode 100644 index 0000000..130a53b --- /dev/null +++ b/src/Actions/VerifyVerificationCode.php @@ -0,0 +1,58 @@ +isLocked()) { + $this->fail($mobile_number, 'locked', SendVerificationCode::lockedMessage($mobile_number), $error_key, 429); + } + + if ($verification_code_id !== null && ! hash_equals((string) $mobile_number->verification_code_id, $verification_code_id)) { + $this->fail($mobile_number, 'mismatch', __('This code is no longer valid. Ask for a new code.'), $error_key); + } + + if ($mobile_number->isVerificationCodeExpired()) { + $this->fail($mobile_number, 'expired', __('The code has expired. Ask for a new code.'), $error_key); + } + + if (! $mobile_number->checkVerificationCode($verification_code)) { + if ($mobile_number->isLocked()) { + MobileNumberLocked::dispatch($mobile_number); + + $this->fail($mobile_number, 'locked', SendVerificationCode::lockedMessage($mobile_number), $error_key, 429); + } + + $this->fail($mobile_number, 'incorrect', trans_choice('The code is incorrect. You have :count attempt left.|The code is incorrect. You have :count attempts left.', $mobile_number->remainingAttempts()), $error_key); + } + + $mobile_number->resetAttempts(); + } + + protected function fail(MobileNumber $mobile_number, string $reason, string $message, string $error_key, int $status = 422): never + { + VerificationCodeFailed::dispatch($mobile_number, $reason); + + throw ValidationException::withMessages([$error_key => $message])->status($status); + } +} diff --git a/src/Auth/MobileLoginMethod.php b/src/Auth/MobileLoginMethod.php new file mode 100644 index 0000000..ced1bb2 --- /dev/null +++ b/src/Auth/MobileLoginMethod.php @@ -0,0 +1,58 @@ + [MobileLoginMethod::class]`, or `MobileVerification::enableFor('user')`. + * Route::authRoutes() then registers the routes in the type's guest group. + */ +class MobileLoginMethod implements LoginMethod +{ + public const string KEY = 'mobile'; + + public function key(): string + { + return self::KEY; + } + + public function label(): string + { + return __('Log in with your mobile number'); + } + + public function routes(Router $router, UserType $type): void + { + $login = MobileVerification::config('controllers.login'); + + $router->get('login/mobile', [$login, 'create'])->name('login.mobile'); + $router->post('login/mobile', [$login, 'store'])->middleware('throttle:mobile-verification.send')->name('login.mobile.store'); + $router->get('login/mobile/verify', [$login, 'verify'])->name('login.mobile.verify'); + $router->post('login/mobile/verify', [$login, 'confirm'])->middleware('throttle:mobile-verification.verify')->name('login.mobile.verify.store'); + $router->post('login/mobile/resend', [$login, 'resend'])->middleware('throttle:mobile-verification.send')->name('login.mobile.resend'); + + if (! MobileVerification::allowsRegistration($type)) { + return; + } + + $register = MobileVerification::config('controllers.register'); + + $router->get('register/mobile', [$register, 'create'])->name('register.mobile'); + $router->post('register/mobile', [$register, 'store'])->middleware('throttle:mobile-verification.send')->name('register.mobile.store'); + $router->get('register/mobile/verify', [$register, 'verify'])->name('register.mobile.verify'); + $router->post('register/mobile/verify', [$register, 'confirm'])->middleware('throttle:mobile-verification.verify')->name('register.mobile.verify.store'); + $router->post('register/mobile/resend', [$register, 'resend'])->middleware('throttle:mobile-verification.send')->name('register.mobile.resend'); + $router->get('register/mobile/details', [$register, 'details'])->name('register.mobile.details'); + $router->post('register/mobile/details', [$register, 'complete'])->name('register.mobile.details.store'); + } + + public function url(UserType $type): string + { + return $type->route('login.mobile'); + } +} diff --git a/src/Concerns/InteractsWithMobileNumbers.php b/src/Concerns/InteractsWithMobileNumbers.php new file mode 100644 index 0000000..4b31371 --- /dev/null +++ b/src/Concerns/InteractsWithMobileNumbers.php @@ -0,0 +1,194 @@ +isForceDeleting()) { + return; + } + + $user->phone()->delete(); + }); + } + + public function phone(): MorphOne + { + return $this->morphOne(MobileVerification::mobileNumberModel(), 'user'); + } + + protected function formattedMobileNumber(): Attribute + { + return Attribute::get(fn () => $this->phone?->formatted_number); + } + + protected function mobileNumber(): Attribute + { + return Attribute::get(fn () => $this->phone?->number); + } + + protected function mobileNumberCountryCode(): Attribute + { + return Attribute::get(fn () => $this->phone?->country_code); + } + + protected function shortMobileNumber(): Attribute + { + return Attribute::get(fn () => $this->phone?->short_formatted_number); + } + + protected function phoneVerified(): Attribute + { + return Attribute::get(fn () => $this->hasVerifiedMobile()); + } + + protected function needsPhoneVerification(): Attribute + { + return Attribute::get(fn () => $this->shouldVerifyPhone() && ! $this->hasVerifiedMobile()); + } + + /** + * Whether this user must have a verified number. Override for users who don't need one. + */ + public function shouldVerifyPhone(): bool + { + return true; + } + + public function hasVerifiedMobile(): bool + { + return $this->phone !== null; + } + + public function scopePhoneVerified(Builder $query): Builder + { + return $query->whereHas('phone'); + } + + public function scopePhoneUnverified(Builder $query): Builder + { + return $query->whereDoesntHave('phone'); + } + + /** + * Users with a number, without the country code for the default one (`7771234`) or with it (`+9607771234`) + */ + public function scopeHasShortMobileNumber(Builder $query, string $mobile_number): Builder + { + return $query->whereHas('phone', fn (Builder $query) => $query->hasNumber($mobile_number)); + } + + public function scopeHasMobileNumber(Builder $query, string $number, ?string $country_code = null): Builder + { + return $query->whereHas('phone', fn (Builder $query) => $query + ->where('number', MobileVerification::normalizeNumber($number)) + ->where('country_code', MobileVerification::normalizeCountryCode($country_code))); + } + + public function scopeHasPhone(Builder $query, string $search): Builder + { + return $query->whereHas('phone', fn (Builder $query) => $query->search($search)); + } + + /** + * Find the user with a verified number + */ + public static function findByMobileNumber(string $number, ?string $country_code = null): ?static + { + return static::query()->hasMobileNumber($number, $country_code)->first(); + } + + public function updatePhone(MobileNumber|int|string $phone, ?Model $causer = null): void + { + if (! $phone instanceof MobileNumber) { + $phone = MobileVerification::mobileNumberModel()::query()->findOrFail($phone); + } + + $old_number = $this->formatted_mobile_number; + + $this->clearPhones($phone); + + $phone->clearVerificationCode(); + $phone->user_type = $this->getMorphClass(); + $phone->user()->associate($this); + $phone->save(); + + $this->unsetRelation('phone'); + + if ($old_number !== $phone->formatted_number) { + MobileNumberUpdated::dispatch($old_number, $phone->formatted_number, $this, $causer); + } + } + + /** + * Give the user a number without a verification code, e.g. from an admin page. + * Returns null when the number belongs to another user of the same kind. + */ + public function updateMobileNumber(string $number, ?string $country_code = null, ?Model $causer = null): ?MobileNumber + { + $phone = MobileVerification::findOrCreate($this, $country_code, $number); + + if ($phone->hasOwner() && ! $phone->user()->is($this)) { + return null; + } + + $this->updatePhone($phone, $causer); + + return $phone; + } + + public function removeMobileNumber(?Model $causer = null): void + { + $old_number = $this->formatted_mobile_number; + + if (! $old_number) { + return; + } + + $this->clearPhones(); + $this->unsetRelation('phone'); + + MobileNumberUpdated::dispatch($old_number, null, $this, $causer); + } + + /** + * Release the user's numbers, keeping one if given + */ + public function clearPhones(?MobileNumber $except = null): void + { + MobileVerification::mobileNumberModel()::query() + ->where('user_id', $this->getKey()) + ->where('user_type', $this->getMorphClass()) + ->when($except?->exists, fn (Builder $query) => $query->whereKeyNot($except->getKey())) + ->update(['user_id' => null]); + } + + public function routeNotificationForSms(): string|array|null + { + return $this->phone?->routeNotificationForSms(); + } +} diff --git a/src/Contracts/CreatesUsersFromMobileNumbers.php b/src/Contracts/CreatesUsersFromMobileNumbers.php new file mode 100644 index 0000000..6caca61 --- /dev/null +++ b/src/Contracts/CreatesUsersFromMobileNumbers.php @@ -0,0 +1,19 @@ + $input the values the registration form validated + */ + public function create(UserType $type, array $input, MobileNumber $mobile_number): Model; +} diff --git a/src/Contracts/GeneratesVerificationCodes.php b/src/Contracts/GeneratesVerificationCodes.php new file mode 100644 index 0000000..66f0894 --- /dev/null +++ b/src/Contracts/GeneratesVerificationCodes.php @@ -0,0 +1,11 @@ +old_phone = $old_phone; - $this->new_phone = $new_phone; - $this->subject = $subject; - $this->causer = $causer; - } + public function __construct( + public ?string $old_number, + public ?string $new_number, + public Model $user, + public ?Model $causer = null, + ) {} } diff --git a/src/Events/VerificationCodeFailed.php b/src/Events/VerificationCodeFailed.php new file mode 100644 index 0000000..7ca36ed --- /dev/null +++ b/src/Events/VerificationCodeFailed.php @@ -0,0 +1,19 @@ + $this->faker->regexify('(9|7)[0-9]{6}'), - 'country_code' => $this->faker->randomElement(config('mobile-verification.allowed_country_codes')), - 'user_type' => $this->faker->randomElement(CauserTypes::getKeys()), - ]; - } -} diff --git a/src/GrantType/MobileGrant.php b/src/GrantType/MobileGrant.php deleted file mode 100644 index e17bfc8..0000000 --- a/src/GrantType/MobileGrant.php +++ /dev/null @@ -1,185 +0,0 @@ -setRefreshTokenRepository($refreshTokenRepository); - $this->refreshTokenTTL = new DateInterval('P1M'); - } - - public function getIdentifier(): string - { - return 'mobile'; - } - - /** - * @throws UniqueTokenIdentifierConstraintViolationException - * @throws OAuthServerException - */ - public function respondToAccessTokenRequest( - ServerRequestInterface $request, - ResponseTypeInterface $responseType, - DateInterval $accessTokenTTL - ): ResponseTypeInterface { - $client = $this->validateClient($request); - $scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope)); - $user = $this->validateUser($request, $client); - - $finalizedScopes = $this->scopeRepository->finalizeScopes($scopes, $this->getIdentifier(), $client, $user->getIdentifier()); - - $accessToken = $this->issueAccessToken( - $accessTokenTTL, - $client, - $user->getIdentifier(), - $finalizedScopes - ); - - $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); - $responseType->setAccessToken($accessToken); - - // Issue and persist a new refresh verification_code - $refreshToken = $this->issueRefreshToken($accessToken); - - if ($refreshToken !== null) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request)); - $responseType->setRefreshToken($refreshToken); - } - - return $responseType; - } - - /** - * @throws OAuthServerException - */ - protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface - { - $validated_data = $this->validateOtp($request, $client); - - /* @var MobileNumber $model */ - $model = MobileVerification::mobileNumberModel(); - $user = $model::getUserByMobileNumber($validated_data['number'], $validated_data['country_code'] ?? null, $validated_data['user_type']); - - if ($user instanceof Authenticatable) { - $user = new UserEntity($user->getAuthIdentifier()); - } - - if (! $user instanceof UserEntityInterface) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request)); - - throw OAuthServerException::invalidGrant(); - } - - return $user; - } - - /** - * @throws OAuthServerException - */ - protected function validateOtp(ServerRequestInterface $request, ClientEntityInterface $client): array - { - // Validate the OTP - // Return the validated data - $mobile_number = $this->getParameter('number', $request); - $verification_code = $this->getParameter('verification_code', $request); - $country_code = $this->getParameter('country_code', $request, false); - $user_type = $this->getUserType($request, $client); - - $validator = Validator::make([ - 'number' => $mobile_number, - 'country_code' => $country_code, - 'verification_code' => $verification_code, - ], [ - 'number' => ['required', 'string', (new IsValidMobileNumber($user_type))->registered()], - 'country_code' => ['nullable', 'string', new IsValidCountryCode()], - 'verification_code' => ['required', 'string', (new IsValidVerificationCode($user_type))->dontVerifyVerficationCodeId()->shouldResetAttempts()], - ]); - - if ($validator->fails()) { - if ($validator->errors()->has('verification_code')) { - throw OAuthServerException::invalidRequest('verification_code', $validator->errors()->first('verification_code')); - } - - if ($validator->errors()->has('number')) { - throw OAuthServerException::invalidRequest('number', $validator->errors()->first('number')); - } - - if ($validator->errors()->has('country_code')) { - throw OAuthServerException::invalidRequest('country_code', $validator->errors()->first('country_code')); - } - - throw OAuthServerException::invalidCredentials(); - } - - return array_merge($validator->validated(), ['user_type' => $user_type]); - } - - /** - * @throws OAuthServerException - */ - protected function getParameter($param, ServerRequestInterface $request, $required = true): ?string - { - $value = $this->getRequestParameter($param, $request); - - if (is_null($value) && $required) { - throw OAuthServerException::invalidRequest($param); - } - - return $value; - } - - /** - * @throws OAuthServerException - */ - protected function getUserType(ServerRequestInterface $request, ClientEntityInterface $client): string - { - $user_provider = $client->provider ?: config('auth.guards.api.provider'); - - if (! in_array($user_provider, config('mobile-verification.mobile_grant_allowed_providers'))) { - throw OAuthServerException::invalidClient($request); - } - - $user_model = config('auth.providers.' . $user_provider . '.model'); - - if (is_null($user_model)) { - throw OAuthServerException::invalidRequest('client_id', 'Unable to determine authentication model from configuration.'); - } - - return (new $user_model())->getMorphClass(); - } - - protected function supportsGrantType(ClientEntityInterface $client, string $grantType): bool - { - $supports = parent::supportsGrantType($client, $grantType); - - // if mobile is not directly supported, fallback to password grant - if ($supports === false && $grantType === $this->getIdentifier()) { - $supports = parent::supportsGrantType($client, 'password'); - } - - return $supports; - } - -} diff --git a/src/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php new file mode 100644 index 0000000..87568be --- /dev/null +++ b/src/Http/Controllers/Controller.php @@ -0,0 +1,77 @@ +userType($request)->auth()->user(); + } + + /** + * @param array $data + */ + protected function render(Request $request, string $page, array $data = [], string $layout = 'auth'): View|Responsable + { + return app(ViewRenderer::class)->render($this->userType($request), $page, $data, $layout); + } + + /** + * Validate the number and country code of a request for a code + * + * @return array{number: string, country_code: string} + */ + protected function validateNumber(Request $request): array + { + $validated = $request->validate([ + 'country_code' => ['nullable', 'string', new IsValidCountryCode], + 'number' => ['required', 'string', 'max:20', new IsValidMobileNumber], + ...(array) MobileVerification::config('request_code_rules', []), + ], attributes: [ + 'number' => __('mobile number'), + 'country_code' => __('country code'), + ]); + + return [ + 'number' => MobileVerification::normalizeNumber($validated['number']), + 'country_code' => MobileVerification::normalizeCountryCode($validated['country_code'] ?? null), + ]; + } + + protected function validateCode(Request $request): string + { + $length = (int) MobileVerification::config('code_length', 6); + + $validated = $request->validate([ + 'verification_code' => ['required', 'string', 'max:' . max(10, $length)], + ], attributes: [ + 'verification_code' => __('code'), + ]); + + return trim($validated['verification_code']); + } +} diff --git a/src/Http/Controllers/MobileApiController.php b/src/Http/Controllers/MobileApiController.php new file mode 100644 index 0000000..5ee1974 --- /dev/null +++ b/src/Http/Controllers/MobileApiController.php @@ -0,0 +1,53 @@ +validateNumber($request); + + $mobile_number = $send($this->userType($request), Purposes::LOGIN, $input['country_code'], $input['number']); + + return new JsonResponse($mobile_number->verificationCodeResponseData()); + } + + public function token(Request $request, AuthenticateMobileNumber $authenticate): JsonResponse + { + $type = $this->userType($request); + + $input = $request->validate([ + 'number' => ['required', 'string', 'max:20'], + 'country_code' => ['nullable', 'string', 'max:5'], + 'verification_code' => ['required', 'string', 'max:10'], + 'verification_code_id' => ['required', 'string', 'max:64'], + 'device_name' => ['required', 'string', 'max:255'], + 'code' => ['nullable', 'string'], + 'recovery_code' => ['nullable', 'string'], + ]); + + $user = $authenticate($type, $input); + + if (! ApiTokens::supports($user)) { + throw ValidationException::withMessages(['number' => __('API tokens are not available for this account.')]); + } + + return new JsonResponse([ + 'token' => ApiTokens::create($user, $input['device_name']), + 'token_type' => 'Bearer', + ], 201); + } +} diff --git a/src/Http/Controllers/MobileLoginController.php b/src/Http/Controllers/MobileLoginController.php new file mode 100644 index 0000000..c4ca121 --- /dev/null +++ b/src/Http/Controllers/MobileLoginController.php @@ -0,0 +1,72 @@ +userType($request); + + return $this->render($request, 'login', [ + 'can_register' => MobileVerification::allowsRegistration($type) && $type->hasRoute('register.mobile'), + ]); + } + + /** @return array */ + protected function pendingExtra(Request $request): array + { + return ['remember' => $request->boolean('remember')]; + } + + protected function verified(Request $request, MobileNumber $mobile_number): Response + { + $type = $this->userType($request); + $pending = PendingVerification::get($request, $type, $this->purpose()) ?? []; + + PendingVerification::forget($request, $type, $this->purpose()); + + $user = MobileVerification::userFor($type, $mobile_number); + + if (! $user) { + return $this->expiredResponse($request); + } + + return app(StartAuthenticatedSession::class)( + $request, + $type, + $user, + remember: (bool) ($pending['remember'] ?? false), + skip_two_factor: MobileVerification::skipsTwoFactor(), + ); + } + + protected function verifyTitle(): string + { + return __('Enter the code to log in'); + } +} diff --git a/src/Http/Controllers/MobileNumberActionsController.php b/src/Http/Controllers/MobileNumberActionsController.php new file mode 100644 index 0000000..643ea4d --- /dev/null +++ b/src/Http/Controllers/MobileNumberActionsController.php @@ -0,0 +1,100 @@ +account($request, $account, 'mobile-verification.update'); + + $validated = $request->validate([ + 'country_code' => ['nullable', 'string', new IsValidCountryCode], + 'number' => ['required', 'string', 'max:20', (new IsValidMobileNumber($type))->notRegistered()->ignore($model)], + ], attributes: [ + 'number' => __('mobile number'), + ]); + + if (! $model->updateMobileNumber($validated['number'], $validated['country_code'] ?? null, $this->causer($request))) { + throw ValidationException::withMessages(['number' => __('This mobile number is already registered.')]); + } + + return $this->respond($request, __('The mobile number has been changed to :number.', ['number' => $model->formatted_mobile_number])); + } + + /** + * Forget the wrong codes against the user's number + */ + public function unlock(Request $request, string $account): Response + { + [$type, $model] = $this->account($request, $account, 'mobile-verification.unlock'); + + $model->phone?->resetAttempts(); + + return $this->respond($request, __('The mobile number has been unlocked.')); + } + + public function destroy(Request $request, string $account): Response + { + [$type, $model] = $this->account($request, $account, 'mobile-verification.remove'); + + $model->removeMobileNumber($this->causer($request)); + + return $this->respond($request, __('The mobile number has been removed.')); + } + + /** + * The managed type and its account, after checking the ability + * + * @return array{0: UserType, 1: Model&HasMobileNumber} + */ + protected function account(Request $request, string $account, string $ability): array + { + $type = UserTypes::get($request->route()->getAction(MobileNumberActionRoutes::ROUTE_ACTION)); + $model = $type->newModel()->resolveRouteBinding($account) ?? abort(404); + + abort_unless($model instanceof HasMobileNumber, 404); + + Gate::forUser($this->causer($request))->authorize($ability, [$type, $model]); + + return [$type, $model]; + } + + /** + * The signed-in admin: the user of whichever guard the request is authenticated on + */ + protected function causer(Request $request): ?Model + { + $type = UserTypes::fromRequest($request); + + return $type?->auth()->user() ?? $request->user(); + } + + protected function respond(Request $request, string $message): Response + { + if ($request->expectsJson()) { + return new JsonResponse(['message' => $message]); + } + + return back()->with('status', $message); + } +} diff --git a/src/Http/Controllers/MobileNumberController.php b/src/Http/Controllers/MobileNumberController.php new file mode 100644 index 0000000..85f5430 --- /dev/null +++ b/src/Http/Controllers/MobileNumberController.php @@ -0,0 +1,73 @@ +user($request); + $phone = $user instanceof HasMobileNumber ? $user->phone : null; + + return $this->render($request, 'settings.mobile-number', [ + 'current_number' => $phone ? [ + 'number' => $phone->number, + 'country_code' => $phone->country_code, + 'formatted_number' => $phone->formatted_number, + ] : null, + ], 'account'); + } + + protected function verified(Request $request, MobileNumber $mobile_number): Response + { + $type = $this->userType($request); + $user = $this->user($request); + + PendingVerification::forget($request, $type, $this->purpose()); + + if ($user instanceof HasMobileNumber) { + $user->updatePhone($mobile_number, $user); + } + + $message = __('Your mobile number is now :number.', ['number' => $mobile_number->formatted_number]); + + if ($request->expectsJson()) { + return new JsonResponse(['verified' => true, 'message' => $message, 'formatted_number' => $mobile_number->formatted_number]); + } + + return redirect()->intended($type->route('mobile-number'))->with('status', $message); + } + + protected function verifyTitle(): string + { + return __('Enter the code to verify your number'); + } +} diff --git a/src/Http/Controllers/MobileRegistrationController.php b/src/Http/Controllers/MobileRegistrationController.php new file mode 100644 index 0000000..986eb8c --- /dev/null +++ b/src/Http/Controllers/MobileRegistrationController.php @@ -0,0 +1,136 @@ +render($request, 'register'); + } + + protected function verified(Request $request, MobileNumber $mobile_number): Response + { + $type = $this->userType($request); + + PendingVerification::markVerified($request, $type, $this->purpose(), $mobile_number); + + $redirect = $type->route('register.mobile.details'); + + if ($request->expectsJson()) { + return new JsonResponse(['verified' => true, 'redirect' => $redirect]); + } + + return redirect()->to($redirect); + } + + protected function verifyTitle(): string + { + return __('Enter the code to verify your number'); + } + + /** + * The registration form, once the number is verified + */ + public function details(Request $request): View|Responsable|Response + { + $type = $this->userType($request); + $mobile_number = $this->verifiedMobileNumber($request); + + if (! $mobile_number) { + return $this->expiredResponse($request); + } + + return $this->render($request, 'register-details', [ + 'form' => $this->form($type), + 'mobile_number' => ['formatted_number' => $mobile_number->formatted_number], + ]); + } + + /** + * Create the user, attach the number and log them in + */ + public function complete(Request $request, CreatesUsersFromMobileNumbers $creator, LoginUser $login): Response + { + $type = $this->userType($request); + $mobile_number = $this->verifiedMobileNumber($request); + + if (! $mobile_number) { + return $this->expiredResponse($request); + } + + if ($mobile_number->hasOwner()) { + PendingVerification::forgetVerified($request, $type, $this->purpose()); + + throw ValidationException::withMessages(['number' => __('This mobile number is already registered.')]) + ->redirectTo($type->route('register.mobile')); + } + + $data = $this->form($type)->validate($request); + + $user = $creator->create($type, $data, $mobile_number); + + if (! $user instanceof HasMobileNumber) { + throw new LogicException($user::class . ' must implement ' . HasMobileNumber::class . ' (use InteractsWithMobileNumbers) for mobile registration.'); + } + + $user->updatePhone($mobile_number); + + PendingVerification::forgetVerified($request, $type, $this->purpose()); + + event(new Registered($user)); + + $login($request, $type, $user); + + return app(RegisterResponse::class)->toResponse($request); + } + + protected function verifiedMobileNumber(Request $request): ?MobileNumber + { + return PendingVerification::verifiedMobileNumber($request, $this->userType($request), $this->purpose()); + } + + protected function form(UserType $type): Form + { + /** @var class-string
$class */ + $class = MobileVerification::config('registration_form', MobileRegistrationForm::class); + + return $class::make() + ->model($type->modelClass()) + ->action($type->route('register.mobile.details.store')) + ->with(['user_type' => $type->name()]); + } +} diff --git a/src/Http/Controllers/VerificationFlowController.php b/src/Http/Controllers/VerificationFlowController.php new file mode 100644 index 0000000..216cf56 --- /dev/null +++ b/src/Http/Controllers/VerificationFlowController.php @@ -0,0 +1,170 @@ + */ + protected function pendingExtra(Request $request): array + { + return []; + } + + /** + * Send a code to the number in the request + */ + public function store(Request $request, SendVerificationCode $send): Response + { + $type = $this->userType($request); + $input = $this->validateNumber($request); + + $mobile_number = $send($type, $this->purpose(), $input['country_code'], $input['number'], $this->user($request)); + + PendingVerification::put($request, $type, $this->purpose(), $mobile_number, $this->pendingExtra($request)); + + return $this->codeSentResponse($request, $mobile_number); + } + + /** + * The page to enter the code + */ + public function verify(Request $request): View|Responsable|Response + { + $mobile_number = $this->pendingMobileNumber($request); + + if (! $mobile_number) { + return redirect()->to($this->userType($request)->route($this->routeName())); + } + + return $this->render($request, 'verify', [ + 'purpose' => $this->purpose()->value, + 'title' => $this->verifyTitle(), + 'mobile_number' => $this->mobileNumberData($mobile_number), + 'verify_url' => $this->userType($request)->route("{$this->routeName()}.verify.store"), + 'resend_url' => $this->userType($request)->route("{$this->routeName()}.resend"), + 'change_url' => $this->userType($request)->route($this->routeName()), + ], $this->layout()); + } + + /** + * Check the code + */ + public function confirm(Request $request, VerifyVerificationCode $verify): Response + { + $code = $this->validateCode($request); + $mobile_number = $this->pendingMobileNumber($request); + + if (! $mobile_number) { + return $this->expiredResponse($request); + } + + $pending = PendingVerification::get($request, $this->userType($request), $this->purpose()); + + $verify($mobile_number, $code, (string) ($pending['verification_code_id'] ?? '')); + + return $this->verified($request, $mobile_number); + } + + /** + * Send another code to the pending number + */ + public function resend(Request $request, SendVerificationCode $send): Response + { + $type = $this->userType($request); + $mobile_number = $this->pendingMobileNumber($request); + + if (! $mobile_number) { + return $this->expiredResponse($request); + } + + $pending = PendingVerification::get($request, $type, $this->purpose()) ?? []; + + $mobile_number = $send($type, $this->purpose(), $mobile_number->country_code, $mobile_number->number, $this->user($request), 'verification_code'); + + PendingVerification::put($request, $type, $this->purpose(), $mobile_number, $pending); + + if ($request->expectsJson()) { + return new JsonResponse($mobile_number->verificationCodeResponseData()); + } + + return back()->with('status', __('We sent a new code to :number.', ['number' => $mobile_number->formatted_number])); + } + + protected function pendingMobileNumber(Request $request): ?MobileNumber + { + return PendingVerification::mobileNumber($request, $this->userType($request), $this->purpose()); + } + + protected function codeSentResponse(Request $request, MobileNumber $mobile_number): Response + { + if ($request->expectsJson()) { + return new JsonResponse($mobile_number->verificationCodeResponseData()); + } + + return redirect() + ->to($this->userType($request)->route("{$this->routeName()}.verify")) + ->with('status', __('We sent a code to :number.', ['number' => $mobile_number->formatted_number])); + } + + protected function expiredResponse(Request $request): Response + { + $message = __('Enter your mobile number again to get a new code.'); + + if ($request->expectsJson()) { + return new JsonResponse(['message' => $message, 'errors' => ['number' => [$message]]], 422); + } + + return redirect() + ->to($this->userType($request)->route($this->routeName())) + ->withErrors(['number' => $message]); + } + + protected function verifyTitle(): string + { + return __('Enter the code'); + } + + /** @return array */ + protected function mobileNumberData(MobileNumber $mobile_number): array + { + return [ + 'formatted_number' => $mobile_number->formatted_number, + 'expires_in' => $mobile_number->verificationCodeExpiresIn(), + 'resend_in' => $mobile_number->resendVerificationCodeIn(), + 'remaining_attempts' => $mobile_number->remainingAttempts(), + ]; + } +} diff --git a/src/Http/Middleware/EnsureMobileIsVerified.php b/src/Http/Middleware/EnsureMobileIsVerified.php new file mode 100644 index 0000000..934e110 --- /dev/null +++ b/src/Http/Middleware/EnsureMobileIsVerified.php @@ -0,0 +1,38 @@ +auth()->user(); + + if (! $user instanceof HasMobileNumber) { + return $next($request); + } + + if ($user->hasVerifiedMobile() || (method_exists($user, 'shouldVerifyPhone') && ! $user->shouldVerifyPhone())) { + return $next($request); + } + + if ($request->expectsJson() || ! $user_type->hasRoute('mobile-number')) { + abort(403, __('Verify your mobile number to continue.')); + } + + return redirect()->guest($user_type->route('mobile-number')) + ->with('status', __('Verify your mobile number to continue.')); + } +} diff --git a/src/Middlewares/AllowMobileVerifiedUsersOnly.php b/src/Middlewares/AllowMobileVerifiedUsersOnly.php deleted file mode 100644 index 9d15706..0000000 --- a/src/Middlewares/AllowMobileVerifiedUsersOnly.php +++ /dev/null @@ -1,32 +0,0 @@ -user()) { - return $next($request); - } - - foreach ($guards as $guard) { - if (auth($guard)->check() && auth($guard)->user()->hasVerifiedMobile()) { - return $next($request); - } - } - - return auth()->user()->redirectToMobileVerificationUrl(); - } -} diff --git a/src/MobileVerification.php b/src/MobileVerification.php index a2fc802..077daba 100644 --- a/src/MobileVerification.php +++ b/src/MobileVerification.php @@ -2,134 +2,198 @@ namespace Javaabu\MobileVerification; -use Illuminate\Http\Request; -use Javaabu\MobileVerification\Contracts\MobileNumber; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Model; +use Javaabu\Auth\UserType; +use Javaabu\Auth\UserTypes; +use Javaabu\MobileVerification\Auth\MobileLoginMethod; +use Javaabu\MobileVerification\Models\MobileNumber; class MobileVerification { + public static bool $runs_migrations = true; + /** - * Get the configs - * @param string $key - * @return mixed + * Stop the package from loading its migration, e.g. after publishing it */ - public static function config(string $key = '') + public static function ignoreMigrations(): void { - $key = $key ? '.'.$key : ''; - - return config('mobile-verification'.$key); + static::$runs_migrations = false; } - /** - * Whether to verify - * - * @return bool + * Add mobile login (and registration) to javaabu/auth user types. Call it before Route::authRoutes(). */ - public static function shouldVerify(): bool + public static function enableFor(string ...$types): void { - return self::config('verified'); + foreach ($types as $type) { + UserTypes::get($type)->loginMethod(MobileLoginMethod::class); + } } /** - * Default country code - * - * @return string + * Whether a user type logs in with a mobile number */ + public static function enabledFor(UserType|string $type): bool + { + $type = is_string($type) ? UserTypes::get($type) : $type; + + foreach ($type->loginMethodsList() as $method) { + if ($method instanceof MobileLoginMethod) { + return true; + } + } + + return false; + } + + public static function config(string $key = '', mixed $default = null): mixed + { + return config('mobile-verification' . ($key ? ".{$key}" : ''), $default); + } + + /** @return class-string */ public static function mobileNumberModel(): string { - return self::config('mobile_number_model'); + return static::config('mobile_number_model', MobileNumber::class); } - public static function findMobileNumberById(string $id): ?MobileNumber + public static function newMobileNumber(): MobileNumber { - $model_class = self::mobileNumberModel(); + $model = static::mobileNumberModel(); - return $model_class::find($id); + return new $model; } - /** - * Default country code - * - * @return string - */ public static function defaultCountryCode(): string { - return self::config('default_country_code'); + return static::normalizeCountryCode((string) static::config('default_country_code', '960'), false); + } + + /** @return array */ + public static function allowedCountryCodes(): array + { + $codes = array_map( + fn ($code) => static::normalizeCountryCode((string) $code, false), + (array) static::config('allowed_country_codes', [static::defaultCountryCode()]), + ); + + return array_values(array_filter($codes)); + } + + public static function numberPrefix(): string + { + return (string) static::config('number_prefix', '+'); } /** - * Allowed country codes + * The allowed country codes for a select: `['960' => '+960']` * - * @return string[] + * @return array */ - public static function allowedCountryCodes(): array + public static function countryCodeOptions(): array { - return self::config('allowed_country_codes'); + $options = []; + + foreach (static::allowedCountryCodes() as $code) { + $options[$code] = static::numberPrefix() . $code; + } + + return $options; } /** - * Default prefix - * - * @return string + * A number as stored: digits only, without leading zeros */ - public static function defaultPrefix(): string + public static function normalizeNumber(?string $number): string { - return self::config('number_prefix').self::defaultCountryCode(); + $number = preg_replace('/[\s\-().]/u', '', (string) $number); + + return ltrim($number, '0'); } /** - * List the allowed country codes - * - * @param ?array $allowed_codes - * @param ?string $number_prefix - * @return array + * A country code as stored: `+960` and `00960` become `960`; empty gives the default */ - public static function listAllowedCountryCodes(?array $allowed_codes = null, ?string $number_prefix = null): array + public static function normalizeCountryCode(?string $country_code, bool $use_default = true): string { - if (is_null($allowed_codes)) { - $allowed_codes = self::allowedCountryCodes(); + $country_code = ltrim(trim((string) $country_code), '+0'); + + if ($country_code === '' && $use_default) { + return static::defaultCountryCode(); } - if (is_null($number_prefix)) { - $number_prefix = self::config('number_prefix'); + return $country_code; + } + + /** + * The value of the `user_type` column for a user type's numbers: its model's morph class + */ + public static function morphClassFor(UserType|Model|string $type): string + { + if ($type instanceof Model) { + return $type->getMorphClass(); } - $list = []; + $type = is_string($type) ? UserTypes::get($type) : $type; + + return $type->newModel()->getMorphClass(); + } + + /** + * The record of a number for a user type, if there is one + */ + public static function find(UserType|Model|string $type, ?string $country_code, ?string $number): ?MobileNumber + { + $number = static::normalizeNumber($number); - foreach ($allowed_codes as $code) { - $list[$code] = $number_prefix.$code; + if ($number === '') { + return null; } - return $list; + return static::mobileNumberModel()::query() + ->hasPhoneNumber(static::normalizeCountryCode($country_code), $number, static::morphClassFor($type)) + ->first(); } /** - * Normalize mobile number - * - * @param string $number - * @return string + * The record of a number for a user type, created without an owner when there is none */ - public static function normalizeNumber(string $number): string + public static function findOrCreate(UserType|Model|string $type, ?string $country_code, string $number): MobileNumber { - return ltrim($number, '0'); + $model = static::mobileNumberModel(); + + return $model::query()->firstOrCreate([ + 'country_code' => static::normalizeCountryCode($country_code), + 'number' => static::normalizeNumber($number), + 'user_type' => static::morphClassFor($type), + ]); } /** - * Normalize mobile number - * - * @param Request $request - * @param string $key - * @return Request + * The user a number belongs to, loaded through the type's guard provider (the model that logs in) */ - public static function normalizePhoneNumberRequest(Request $request, string $key = 'phone'): Request + public static function userFor(UserType $type, ?MobileNumber $mobile_number): ?Authenticatable { - $number = $request->input($key); + if (! $mobile_number?->hasOwner()) { + return null; + } - if ($number) { - $number = self::normalizeNumber($number); - $request->merge([$key => $number]); + if ($mobile_number->user_type !== static::morphClassFor($type)) { + return null; } - return $request; + return $type->auth()->getProvider()->retrieveById($mobile_number->user_id); + } + + public static function skipsTwoFactor(): bool + { + return (bool) static::config('skip_two_factor', false); + } + + public static function allowsRegistration(UserType $type): bool + { + return static::config('registration', true) + && $type->hasFeature(UserType::REGISTRATION); } } diff --git a/src/MobileVerificationServiceProvider.php b/src/MobileVerificationServiceProvider.php index 15ed658..3e4e486 100644 --- a/src/MobileVerificationServiceProvider.php +++ b/src/MobileVerificationServiceProvider.php @@ -2,109 +2,235 @@ namespace Javaabu\MobileVerification; -use Carbon\Carbon; -use Illuminate\Support\Str; -use Laravel\Passport\Passport; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; -use League\OAuth2\Server\AuthorizationServer; -use Laravel\Passport\Bridge\RefreshTokenRepository; -use Javaabu\MobileVerification\GrantType\MobileGrant; +use Javaabu\Auth\UserTypes; +use Javaabu\Manifest\Concerns\RegistersAssets; +use Javaabu\Manifest\Package; +use Javaabu\MobileVerification\Actions\CreateUserFromMobileNumber; +use Javaabu\MobileVerification\Contracts\CreatesUsersFromMobileNumbers; +use Javaabu\MobileVerification\Contracts\GeneratesVerificationCodes; +use Javaabu\MobileVerification\Contracts\ValidatesNumberFormat; +use Javaabu\MobileVerification\Http\Middleware\EnsureMobileIsVerified; +use Javaabu\MobileVerification\Passport\MobileGrant; +use Javaabu\MobileVerification\Policies\MobileNumberPolicy; +use Javaabu\MobileVerification\Routing\MobileApiRoutes; +use Javaabu\MobileVerification\Routing\MobileNumberActionRoutes; +use Javaabu\MobileVerification\Routing\MobileNumberRoutes; +use Javaabu\MobileVerification\Support\NumberFormatValidator; use Javaabu\MobileVerification\Support\VerificationCodeGenerator; -use Javaabu\MobileVerification\Middlewares\AllowMobileVerifiedUsersOnly; +use Javaabu\Permissions\Permissions; +use Laravel\Passport\Bridge\RefreshTokenRepository; +use Laravel\Passport\Passport; +use League\OAuth2\Server\AuthorizationServer; class MobileVerificationServiceProvider extends ServiceProvider { - /** - * The package migrations, in order of creation. - * - * @var array|string[] - */ - protected array $migrations = [ - 'create_mobile_numbers_table' + use RegistersAssets; + + /** Route middleware the package registers */ + public const array MIDDLEWARE = [ + 'mobile.verified' => EnsureMobileIsVerified::class, + // The v2 name + 'mobile-verified' => EnsureMobileIsVerified::class, ]; - /** - * Bootstrap the application services. - */ - public function boot() + /** Default implementations apps can replace */ + public const array BINDINGS = [ + CreatesUsersFromMobileNumbers::class => CreateUserFromMobileNumber::class, + ]; + + public function register(): void { - // declare publishes - if ($this->app->runningInConsole()) { - // Publish migrations with current timestamp - foreach ($this->migrations as $migration) { - $vendorMigration = __DIR__ . '/../database/migrations/' . $migration . '.php'; - $appMigration = $this->generateMigrationName($migration, now()->addSecond()); - - $this->publishes([ - $vendorMigration => $appMigration, - ], 'mobile-verification-migrations'); - } + $this->mergeConfigFrom(__DIR__ . '/../config/mobile-verification.php', 'mobile-verification'); + + foreach (self::BINDINGS as $contract => $implementation) { + $this->app->bindIf($contract, $implementation); + } - $this->publishes([ - __DIR__ . '/../config/mobile-verification.php' => config_path('mobile-verification.php'), - ], 'mobile-verification-config'); + // The classes in config, unless the app binds the contracts itself + $this->app->bindIf(GeneratesVerificationCodes::class, fn () => $this->app->make(MobileVerification::config('code_generator') ?: VerificationCodeGenerator::class)); + $this->app->bindIf(ValidatesNumberFormat::class, fn () => $this->app->make(MobileVerification::config('number_format_validator') ?: NumberFormatValidator::class)); - $this->publishes([ - __DIR__ . '/../lang' => lang_path('vendor/mobile-verification'), - ], 'mobile-verification-translations'); + $this->registerPassportGrant(); + } + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->registerPublishing(); } - $this->loadTranslationsFrom(__DIR__.'/../lang', 'mobile-verification'); + $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'mobile-verification'); + + if (MobileVerification::$runs_migrations) { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + + $this->registerFrontendAssets(); + $this->registerMiddleware(); + $this->registerRouteMacros(); + $this->registerRateLimiters(); + $this->registerAbilities(); + $this->registerAccountLink(); + + $this->app->booted(fn () => $this->registerPermissions()); + } + + protected function registerPublishing(): void + { + $this->publishes([ + __DIR__ . '/../config/mobile-verification.php' => config_path('mobile-verification.php'), + ], 'mobile-verification-config'); + + $this->publishes([ + __DIR__ . '/../resources/views' => resource_path('views/vendor/mobile-verification'), + ], 'mobile-verification-views'); + + $this->publishes([ + __DIR__ . '/../lang' => lang_path('vendor/mobile-verification'), + ], 'mobile-verification-translations'); + + $this->publishesMigrations([ + __DIR__ . '/../database/migrations' => database_path('migrations'), + ], 'mobile-verification-migrations'); } /** - * Register the application services. + * The Inertia pages (mobile-verification::Login, …) and the code apps can import as + * `@javaabu/mobile-verification`, through javaabu/manifest. The Blade pages register no + * assets, so every npm package is for the pages: Blade apps aren't asked to install React. */ - public function register() + protected function registerFrontendAssets(): void { - // merge package config with user defined config - $this->mergeConfigFrom(__DIR__ . '/../config/mobile-verification.php', 'mobile-verification'); + $this->registerAssets('mobile-verification', __DIR__ . '/../resources', function (Package $package) { + $package + ->js() + ->pages() + ->npm([ + '@javaabu/forms' => 'github:Javaabu/react-forms#next-gen', + '@javaabu/onu' => '^0.2.1', + ], peer_dependencies: [ + 'react' => '^18.3 || ^19.0', + 'react-dom' => '^18.3 || ^19.0', + '@inertiajs/react' => '^2.0 || ^3.0', + ], for: 'pages'); + }); + } - $this->app->resolving(AuthorizationServer::class, function (AuthorizationServer $server) { - $grant = $this->makeGrant(); - $server->enableGrantType($grant, Passport::tokensExpireIn()); + protected function registerMiddleware(): void + { + $router = $this->app->make(Router::class); + + foreach (self::MIDDLEWARE as $alias => $middleware) { + $router->aliasMiddleware($alias, $middleware); + } + } + + protected function registerRouteMacros(): void + { + Route::macro('mobileNumberRoutes', function (?string $type = null, array $options = []) { + MobileNumberRoutes::register($this, $type, $options); }); - app('router')->aliasMiddleware('mobile-verified', AllowMobileVerifiedUsersOnly::class); + Route::macro('mobileApiRoutes', function (?string $type = null, array $options = []) { + MobileApiRoutes::register($this, $type, $options); + }); - $this->app->bind(VerificationCodeGenerator::class, function () { - return new VerificationCodeGenerator(); + Route::macro('mobileNumberActions', function (string $type, array $options = []) { + MobileNumberActionRoutes::register($this, $type, $options); }); } - protected function makeGrant(): MobileGrant + /** + * The `mobile` grant, when laravel/passport is installed + */ + protected function registerPassportGrant(): void { - $grant = new MobileGrant( - $this->app->make(RefreshTokenRepository::class) - ); + if (! class_exists(Passport::class)) { + return; + } - $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn()); + $this->app->afterResolving(AuthorizationServer::class, function (AuthorizationServer $server) { + if (! MobileVerification::config('passport_grant', true)) { + return; + } + + $grant = new MobileGrant($this->app->make(RefreshTokenRepository::class)); + $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn()); - return $grant; + $server->enableGrantType($grant, Passport::tokensExpireIn()); + }); + } + + /** + * The account page in javaabu/auth's account navigation, for every type with Route::mobileNumberRoutes() + */ + protected function registerAccountLink(): void + { + UserTypes::accountLink('mobile-number', 'Mobile number', position: 30); } - protected function generateMigrationName(string $migrationFileName, Carbon $now): string + /** + * The abilities of MobileNumberPolicy (or mobile-verification.policy), unless the app defined them + */ + protected function registerAbilities(): void { - $migrationsPath = 'migrations/' . dirname($migrationFileName) . '/'; - $migrationFileName = basename($migrationFileName); + $policy = MobileVerification::config('policy', MobileNumberPolicy::class); - $len = strlen($migrationFileName) + 4; + foreach (MobileNumberPolicy::ABILITIES as $ability => $action) { + if (Gate::has($ability)) { + continue; + } - if (Str::contains($migrationFileName, '/')) { - $migrationsPath .= Str::of($migrationFileName)->beforeLast('/')->finish('/'); - $migrationFileName = Str::of($migrationFileName)->afterLast('/'); + Gate::define($ability, [$policy, $action]); + } + } + + /** + * Permissions for managing each user type's numbers, through javaabu/permissions + */ + protected function registerPermissions(): void + { + if (! MobileVerification::config('register_permissions', true)) { + return; } - foreach (glob(database_path("{$migrationsPath}*.php")) as $filename) { - if ((substr($filename, -$len) === $migrationFileName . '.php')) { - return $filename; + $managed = MobileVerification::config('managed_user_types') ?: array_keys(UserTypes::all()); + + foreach ($managed as $name) { + $type = UserTypes::get($name); + $group = MobileNumberPolicy::group($type); + $permissions = []; + + foreach (MobileNumberPolicy::DESCRIPTIONS as $action => $description) { + // English, as javaabu/permissions translates descriptions when it shows them + $permissions[MobileNumberPolicy::permission($action, $type)] = strtr($description, [':resource' => str_replace('_', ' ', $group)]); } + + Permissions::register($group, $permissions); } + } - $timestamp = $now->format('Y_m_d_His'); - $migrationFileName = Str::of($migrationFileName)->snake()->finish('.php'); + protected function registerRateLimiters(): void + { + $limit = fn (string $key, int $default) => (int) MobileVerification::config("rate_limits.{$key}", $default); + $type = fn (Request $request) => UserTypes::fromRequest($request)?->name() ?? 'default'; - return database_path($migrationsPath . $timestamp . '_' . $migrationFileName); + RateLimiter::for('mobile-verification.send', function (Request $request) use ($limit, $type) { + return Limit::perMinute($limit('send', 5))->by("{$type($request)}|{$request->ip()}"); + }); + + RateLimiter::for('mobile-verification.verify', function (Request $request) use ($limit, $type) { + $session = $request->hasSession() ? $request->session()->getId() : ''; + + return Limit::perMinute($limit('verify', 10))->by("{$type($request)}|{$request->ip()}|{$session}"); + }); } } diff --git a/src/Models/MobileNumber.php b/src/Models/MobileNumber.php index ce7a6f0..31a797a 100644 --- a/src/Models/MobileNumber.php +++ b/src/Models/MobileNumber.php @@ -2,453 +2,374 @@ namespace Javaabu\MobileVerification\Models; -use Carbon\Carbon; -use Illuminate\Support\Str; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; +use Carbon\CarbonInterface; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Notifications\Notifiable; -use Illuminate\Database\Eloquent\Builder; -use Javaabu\Activitylog\Traits\LogsActivity; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; +use Javaabu\MobileVerification\Contracts\GeneratesVerificationCodes; +use Javaabu\MobileVerification\Database\Factories\MobileNumberFactory; use Javaabu\MobileVerification\MobileVerification; -use Illuminate\Database\Eloquent\Relations\MorphTo; use Javaabu\SmsNotifications\Notifiable\HasSmsNumber; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use Javaabu\MobileVerification\Contracts\HasMobileNumber; -use Javaabu\MobileVerification\Factories\MobileNumberFactory; -use Javaabu\MobileVerification\Support\VerificationCodeUpdater; -use Javaabu\MobileVerification\Support\VerificationCodeGenerator; -use Javaabu\MobileVerification\Contracts\MobileNumber as MobileNumberContract; - -class MobileNumber extends Model implements MobileNumberContract +use Javaabu\SmsNotifications\Notifiable\SmsNotifiable; + +/** + * A mobile number, owned by a user once it's verified. It holds the current verification code + * (hashed), when it was sent, and the wrong attempts against it. + * + * @property int $id + * @property string $number + * @property string $country_code + * @property string $user_type + * @property int|string|null $user_id + * @property int $attempts + * @property ?string $verification_code + * @property ?CarbonInterface $verification_code_created_at + * @property ?string $verification_code_id + * @property-read string $formatted_number + * @property-read string $short_formatted_number + * @property-read string $prefix + * @property-read ?Model $user + */ +class MobileNumber extends Model implements SmsNotifiable { use HasFactory; use HasSmsNumber; - use LogsActivity; use Notifiable; - /** - * The attributes that are mass assignable. - * - * @var array - */ protected $fillable = [ 'number', 'country_code', 'user_type', ]; - /** - * Dates - * - * @var array - */ - protected $casts = [ - 'verification_code_created_at' => 'datetime', - ]; - - /** - * Hidden - * - * @var array - */ protected $hidden = [ 'verification_code', ]; - /** - * Appends - * - * @var array - */ protected $appends = [ 'formatted_number', - 'verification_code_expires_in', - 'resend_verification_code_in', ]; - protected static array $logExceptAttributes = [ - 'verification_code', - ]; + public function getConnectionName(): ?string + { + return MobileVerification::config('database_connection') ?: parent::getConnectionName(); + } - /** - * Create a new factory instance for the model. - * - * @return \Illuminate\Database\Eloquent\Factories\Factory - */ - protected static function newFactory() + public function getTable(): string { - return new MobileNumberFactory(); + return MobileVerification::config('table_name') ?: parent::getTable(); + } + + protected function casts(): array + { + return [ + 'attempts' => 'integer', + 'verification_code_created_at' => 'datetime', + ]; + } + + protected static function newFactory(): MobileNumberFactory + { + return MobileNumberFactory::new(); } - /** - * A mobile number belongs to a user - * - * @return MorphTo - */ public function user(): MorphTo { return $this->morphTo('user'); } - /** - * A search scope - * - * @param $query - * @param $search - * @return + * Numbers containing the search, with the prefix and country code: `+9607771234` */ public function scopeSearch(Builder $query, string $search): Builder { - return $query->where(DB::raw('CONCAT(\'' . MobileVerification::config('number_prefix') . '\', country_code, number)'), 'like', '%' . $search . '%'); + $search = str_replace(['%', '_'], ['\%', '\_'], ltrim($search, MobileVerification::numberPrefix())); + + return $query->where(function (Builder $query) use ($search) { + $query->where('number', 'like', "%{$search}%") + ->orWhereRaw($this->concatCountryCodeAndNumber() . ' like ?', ["%{$search}%"]); + }); } /** - * Find mobile number without using country code. - * By default would assume it's using default country code, - * unless the number starts with a plus +. - * - * @param $query - * @param $search + * A number written the way users write it: with the prefix and country code (`+9607771234`), + * or without them for the default country code (`7771234`) */ public function scopeHasNumber(Builder $query, string $search): Builder { - $prefix = self::config('number_prefix'); - - // check if starts with the country code - if (Str::startsWith($search, $prefix)) { - // extract the number without the prefix - $number = Str::after($search, $prefix); + $prefix = MobileVerification::numberPrefix(); - return $query->where(DB::raw('CONCAT(country_code, number)'), $number); + if ($prefix !== '' && str_starts_with($search, $prefix)) { + return $query->whereRaw($this->concatCountryCodeAndNumber() . ' = ?', [Str::after($search, $prefix)]); } - // otherwise check against the default country code - return $query->where('number', $search) - ->where('country_code', MobileVerification::defaultCountryCode()); + return $query->where('number', MobileVerification::normalizeNumber($search)) + ->where('country_code', MobileVerification::defaultCountryCode()); } - public function scopeHasPhoneNumberWithOwner($query, string $country_code, string $number, string $user_type): void + public function scopeHasPhoneNumber(Builder $query, string $country_code, string $number, string $user_type): Builder { - $query->hasPhoneNumber($country_code, $number, $user_type) - ->where('user_id', '!=', null); + return $query->where('country_code', MobileVerification::normalizeCountryCode($country_code)) + ->where('number', MobileVerification::normalizeNumber($number)) + ->where('user_type', $user_type); } - public function scopeHasPhoneNumberWithoutOwner($query, string $country_code, string $number, string $user_type): void + public function scopeHasPhoneNumberWithOwner(Builder $query, string $country_code, string $number, string $user_type): Builder { - $query->hasPhoneNumber($country_code, $number, $user_type) - ->whereNull('user_id'); + return $query->hasPhoneNumber($country_code, $number, $user_type)->whereNotNull('user_id'); } - public function scopeHasPhoneNumber($query, string $country_code, string $number, string $user_type): void + public function scopeHasPhoneNumberWithoutOwner(Builder $query, string $country_code, string $number, string $user_type): Builder { - $number = MobileVerification::normalizeNumber($number); - $query->where('country_code', $country_code) - ->where('number', $number) - ->where('user_type', $user_type); + return $query->hasPhoneNumber($country_code, $number, $user_type)->whereNull('user_id'); } - - /** - * Convert dates to Carbon - * @param $date - */ - public function setTokenCreatedAtAttribute($date) + protected function number(): Attribute { - $this->attributes['verification_code_created_at'] = empty($date) ? null : Carbon::parse($date); + return Attribute::make( + set: fn (?string $value) => MobileVerification::normalizeNumber($value), + ); } - /** - * Normalize mobile numbers before saving - * @param $number - */ - public function setNumberAttribute($number) + protected function countryCode(): Attribute { - $this->attributes['number'] = MobileVerification::normalizeNumber($number); + return Attribute::make( + set: fn (?string $value) => MobileVerification::normalizeCountryCode($value), + ); } /** - * Default to the default country code - * @param $value + * Codes are stored hashed */ - public function setCountryCodeAttribute($value) + protected function verificationCode(): Attribute { - $this->attributes['country_code'] = $value ?: MobileVerification::defaultCountryCode(); + return Attribute::make( + set: fn (?string $value) => $value ? Hash::make($value) : null, + ); } - /** - * Hash the verification_code before saving - * @param $value - */ - public function setVerificationCodeAttribute($value) + protected function prefix(): Attribute { - $this->attributes['verification_code'] = $value ? Hash::make($value) : null; + return Attribute::get(fn () => $this->country_code ? MobileVerification::numberPrefix() . $this->country_code : ''); } - public function hasUnexpiredVerificationCode(): bool + protected function formattedNumber(): Attribute { - return $this->verification_code && (! $this->is_verification_code_expired); + return Attribute::get(fn () => $this->prefix . $this->number); } /** - * Generate the verification_code + * The number without the country code when it has the default one */ - public function randomVerificationCode(): string + protected function shortFormattedNumber(): Attribute { - return str_pad(rand(0, 999999), 6, 0, STR_PAD_LEFT); + return Attribute::get(fn () => $this->country_code === MobileVerification::defaultCountryCode() + ? (string) $this->number + : $this->formatted_number); } - /** - * Clear the verification_code fields - */ - public function clearVerificationCode(): void + public function hasOwner(): bool { - $this->attempts = 0; - $this->verification_code = null; - $this->verification_code_created_at = null; - $this->verification_code_id = null; + return ! empty($this->user_id); } /** - * Generate the verification_code and save + * Generate a new code, save it (hashed) and return it, to send to the number */ public function generateVerificationCode(): string { - /* @var VerificationCodeUpdater $verificationCodeUpdater */ - $verificationCodeUpdater = app(VerificationCodeUpdater::class); - return $verificationCodeUpdater->handle($this); - } + $code = app(GeneratesVerificationCodes::class)->generate(); - /** - * Check if was sent recently - * @return bool - */ - public function getWasSentRecentlyAttribute(): bool - { - return $this->verification_code_created_at && $this->verification_code_created_at->diffInSeconds() < MobileVerification::config('resend_interval'); - } + $this->attempts = 0; + $this->verification_code = $code; + $this->verification_code_created_at = now(); + $this->verification_code_id = (string) Str::uuid(); + $this->save(); - public function getResendVerificationCodeInAttribute(): int - { - return $this->was_sent_recently - ? MobileVerification::config('resend_interval') - $this->verification_code_created_at->diffInSeconds() - : 0; + return $code; } - /** - * Check if verification_code is expired - */ - public function getIsVerificationCodeExpiredAttribute(): bool + public function hasVerificationCode(): bool { - return ! $this->verification_code || - ($this->verification_code_created_at && - $this->verification_code_created_at->diffInMinutes() >= $this->verification_code_expires_in); + return ! empty($this->getAttributes()['verification_code'] ?? null); } /** - * Get verification_code expires at time + * Check a code. A wrong one counts as an attempt. */ - public function getVerificationCodeExpiresAtAttribute(): Carbon + public function checkVerificationCode(?string $code): bool { - return $this->getVerificationCodeInitiatedTime()->addMinutes($this->verification_code_expires_in); - } + $code = trim((string) $code); - /** - * Get verification_code expiry in seconds - */ - public function getVerificationCodeExpiryAttribute(): int - { - return $this->is_verification_code_expired ? 0 : now()->diffInSeconds($this->verification_code_expires_at); - } + if ($code !== '' && $this->hasVerificationCode() && Hash::check($code, $this->getAttributes()['verification_code'])) { + return true; + } - /** - * Get verification_code expires in attribute - */ - public function getVerificationCodeExpiresInAttribute(): int - { - return MobileVerification::config('verification_code_validity'); - } + $this->attempts++; + $this->save(); - /** - * Check if number is locked - */ - public function getIsLockedAttribute(): bool - { - return $this->attempts >= MobileVerification::config('max_attempts'); + return false; } /** - * Get the verification_code created at time or the current time - * @return Carbon + * Forget the code and the attempts, without saving */ - public function getVerificationCodeInitiatedTime(): Carbon + public function clearVerificationCode(): static { - return $this->verification_code_created_at ?: Carbon::now(); - } + $this->attempts = 0; + $this->verification_code = null; + $this->verification_code_created_at = null; + $this->verification_code_id = null; - /** - * Check if can request for new code - */ - public function getCanRequestCodeAttribute(): bool - { - return ! $this->is_locked || - $this->getVerificationCodeInitiatedTime()->diffInMinutes() >= MobileVerification::config('attempt_expiry'); + return $this; } /** - * Attempts expiry at + * Forget the code and the attempts, unlocking the number */ - public function getAttemptsExpiryAtAttribute(): Carbon + public function resetAttempts(): void { - return ! $this->is_locked ? Carbon::now() : - $this->getVerificationCodeInitiatedTime()->addMinutes(MobileVerification::config('attempt_expiry')); + $this->clearVerificationCode()->save(); } - /** - * Get attempts expiry minutes - */ - public function getAttemptsExpiryAttribute(): int + /** Minutes a code stays valid */ + public function codeValidity(): int { - return $this->can_request_code ? 0 : $this->attempts_expiry_at->diffInMinutes(); + return (int) MobileVerification::config('verification_code_validity', 10); } - public function getAttemptsExpirySecondsAttribute(): int + public function verificationCodeExpiresAt(): ?CarbonInterface { - return $this->can_request_code ? 0 : $this->attempts_expiry_at->diffInSeconds(); + return $this->verification_code_created_at?->copy()->addMinutes($this->codeValidity()); } - /** - * Verify verification_code - */ - public function verifyVerificationCode($verification_code, bool $should_reset = false): bool + public function isVerificationCodeExpired(): bool { - if ($verification_code && $this->verification_code && Hash::check($verification_code, $this->verification_code)) { - - if ($should_reset) { - $this->resetAttempts(); - } - + if (! $this->hasVerificationCode()) { return true; } - $this->attempts++; - $this->save(); + $expires_at = $this->verificationCodeExpiresAt(); - return false; + return ! $expires_at || $expires_at->lessThanOrEqualTo(now()); } - public function resetAttempts(): void + /** Seconds until the code expires */ + public function verificationCodeExpiresIn(): int { - $this->attempts = 0; - $this->verification_code = null; - $this->verification_code_created_at = null; - $this->verification_code_id = null; - $this->save(); + return $this->isVerificationCodeExpired() + ? 0 + : $this->secondsUntil($this->verificationCodeExpiresAt()); } - /** - * Get the prefix - * @return string - */ - public function getPrefixAttribute(): string + public function wasSentRecently(): bool { - return $this->country_code ? MobileVerification::config('number_prefix') . $this->country_code : ''; + return $this->resendVerificationCodeIn() > 0; } - /** - * Get the formatted number - */ - public function getFormattedNumberAttribute(): string + /** Seconds before another code can be sent */ + public function resendVerificationCodeIn(): int { - return $this->prefix . $this->number; + if (! $this->verification_code_created_at) { + return 0; + } + + return $this->secondsUntil( + $this->verification_code_created_at->copy()->addSeconds((int) MobileVerification::config('resend_interval', 30)), + ); } /** - * Get the short formatted number + * Locked after too many wrong codes, until attempt_expiry minutes after the code was sent */ - public function getShortFormattedNumberAttribute(): string + public function isLocked(): bool { - return $this->country_code == MobileVerification::defaultCountryCode() ? - $this->number : - $this->prefix . $this->number; + return $this->lockExpiresIn() > 0; } - /** - * The number to send SMSs to - * - * @return string - */ - public function routeNotificationForSms(): array|string|null + public function hasTooManyAttempts(): bool { - return $this->formatted_number; + return $this->attempts >= (int) MobileVerification::config('max_attempts', 5); } - /** - * Check if is same - * @param MobileNumber $other - * @return bool - */ - public function isSame(\Javaabu\MobileVerification\Contracts\MobileNumber $other): bool + public function lockExpiresAt(): ?CarbonInterface { - return $this->country_code == $other->country_code - && $this->number == $other->number; + if (! $this->hasTooManyAttempts()) { + return null; + } + + return ($this->verification_code_created_at ?? $this->updated_at ?? now()) + ->copy() + ->addMinutes((int) MobileVerification::config('attempt_expiry', 30)); } - /** - * Blank number - * @param $number - * @param string $country_code - * @param string $user_type - * @return MobileNumber|null - */ - public static function blankPhone(string $number, string $country_code = '', string $user_type = 'user'): ?\Javaabu\MobileVerification\Contracts\MobileNumber + /** Seconds until the number unlocks */ + public function lockExpiresIn(): int { - $country_code = $country_code ?: MobileVerification::defaultCountryCode(); - $number = MobileVerification::normalizeNumber($number); + $expires_at = $this->lockExpiresAt(); - $model = MobileVerification::mobileNumberModel(); - $phone = $model::firstOrCreate(compact( - 'number', - 'country_code', - 'user_type' - )); - - if (! $phone->user_id) { - return $phone; - } + return $expires_at ? $this->secondsUntil($expires_at) : 0; + } - return null; + public function remainingAttempts(): int + { + return max(0, (int) MobileVerification::config('max_attempts', 5) - $this->attempts); } - public static function getUserByMobileNumber(string $number, string $country_code = '', string $user_type = 'user'): ?HasMobileNumber + public function canRequestCode(): bool { - $country_code = $country_code ?: MobileVerification::defaultCountryCode(); - $number = MobileVerification::normalizeNumber($number); + return ! $this->isLocked() && ! $this->wasSentRecently(); + } - $model = MobileVerification::mobileNumberModel(); - $phone = $model::query()->where('number', $number) - ->where('country_code', $country_code) - ->where('user_type', $user_type) - ->first(); + public function isSame(MobileNumber $other): bool + { + return $this->country_code === $other->country_code + && $this->number === $other->number; + } - return $phone->user; + public function routeNotificationForSms(): string|array|null + { + return $this->formatted_number; } + /** + * What JSON responses say about the code + * + * @return array + */ public function verificationCodeResponseData(): array { return [ + 'number' => $this->number, + 'country_code' => $this->country_code, + 'formatted_number' => $this->formatted_number, 'verification_code_id' => $this->verification_code_id, - 'user_type' => $this->user_type, - 'is_registered' => (bool)$this->user_id, - 'expires_at' => $this->verification_code_expires_at, - 'expiry_duration' => $this->verification_code_expiry, - 'expires_in' => $this->verification_code_expires_in, - 'resend_interval' => MobileVerification::config('resend_interval'), - 'resend_in' => $this->resend_verification_code_in, + 'is_registered' => $this->hasOwner(), + 'expires_at' => $this->verificationCodeExpiresAt()?->toIso8601String(), + 'expires_in' => $this->verificationCodeExpiresIn(), + 'resend_interval' => (int) MobileVerification::config('resend_interval', 30), + 'resend_in' => $this->resendVerificationCodeIn(), 'attempts' => $this->attempts, - 'is_locked' => $this->is_locked, - 'attempt_expiry' => $this->attempts_expiry, + 'remaining_attempts' => $this->remainingAttempts(), + 'is_locked' => $this->isLocked(), + 'unlocks_in' => $this->lockExpiresIn(), ]; } + + protected function secondsUntil(CarbonInterface $time): int + { + return max(0, (int) ceil(now()->diffInSeconds($time, false))); + } + + protected function concatCountryCodeAndNumber(): string + { + return $this->getConnection()->getDriverName() === 'sqlite' + ? '(country_code || number)' + : 'CONCAT(country_code, number)'; + } } diff --git a/src/Notifications/LoginVerificationTokenNotification.php b/src/Notifications/LoginVerificationTokenNotification.php deleted file mode 100644 index 638429b..0000000 --- a/src/Notifications/LoginVerificationTokenNotification.php +++ /dev/null @@ -1,57 +0,0 @@ -verification_code = $verification_code; - $this->name = $name; - } - - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * @return array - */ - public function via($notifiable) - { - return [$this->getSmsChannel()]; - } - - /** - * Get the sms representation of the notification. - * - * @param mixed $notifiable - * @return string - */ - public function toSms($notifiable): string - { - return "Dear ".($this->name ?: 'User').",\n". - 'Your '.get_setting('app_name').' account verification code is '.$this->verification_code; - } -} diff --git a/src/Notifications/MobileNumberVerificationToken.php b/src/Notifications/MobileNumberVerificationToken.php deleted file mode 100644 index 92dced2..0000000 --- a/src/Notifications/MobileNumberVerificationToken.php +++ /dev/null @@ -1,57 +0,0 @@ -verification_code = $verification_code; - $this->name = $name; - } - - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * @return array - */ - public function via($notifiable) - { - return [$this->getSmsChannel()]; - } - - /** - * Get the sms representation of the notification. - * - * @param mixed $notifiable - * @return string - */ - public function toSms($notifiable): string - { - return "Dear ".($this->name ?: 'User').",\n". - 'Your '.get_setting('app_name').' account verification code is '.$this->verification_code; - } -} diff --git a/src/Notifications/RegisterVerificationTokenNotification.php b/src/Notifications/RegisterVerificationTokenNotification.php deleted file mode 100644 index d40efc1..0000000 --- a/src/Notifications/RegisterVerificationTokenNotification.php +++ /dev/null @@ -1,57 +0,0 @@ -verification_code = $verification_code; - $this->name = $name; - } - - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * @return array - */ - public function via($notifiable) - { - return [$this->getSmsChannel()]; - } - - /** - * Get the sms representation of the notification. - * - * @param mixed $notifiable - * @return string - */ - public function toSms($notifiable): string - { - return "Dear ".($this->name ?: 'User').",\n". - 'Your '.get_setting('app_name').' account verification code is '.$this->verification_code; - } -} diff --git a/src/Notifications/VerificationCodeNotification.php b/src/Notifications/VerificationCodeNotification.php new file mode 100644 index 0000000..ca8a231 --- /dev/null +++ b/src/Notifications/VerificationCodeNotification.php @@ -0,0 +1,40 @@ +locale(app()->getLocale()); + } + + /** @return array */ + public function via(object $notifiable): array + { + return [$this->getSmsChannel()]; + } + + public function toSms($notifiable): string + { + return __('Your :app verification code is :code. It expires in :minutes minutes. Do not share it with anyone.', [ + 'app' => config('app.name'), + 'code' => $this->verification_code, + 'minutes' => (int) MobileVerification::config('verification_code_validity', 10), + ]); + } +} diff --git a/src/Passport/MobileGrant.php b/src/Passport/MobileGrant.php new file mode 100644 index 0000000..a970d25 --- /dev/null +++ b/src/Passport/MobileGrant.php @@ -0,0 +1,128 @@ +setRefreshTokenRepository($refresh_token_repository); + $this->refreshTokenTTL = new DateInterval('P1M'); + } + + public function getIdentifier(): string + { + return 'mobile'; + } + + public function respondToAccessTokenRequest( + ServerRequestInterface $request, + ResponseTypeInterface $responseType, + DateInterval $accessTokenTTL, + ): ResponseTypeInterface { + $client = $this->validateClient($request); + $scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope)); + $user = $this->validateUser($request, $client); + + $finalized_scopes = $this->scopeRepository->finalizeScopes($scopes, $this->getIdentifier(), $client, $user->getIdentifier()); + + $access_token = $this->issueAccessToken($accessTokenTTL, $client, $user->getIdentifier(), $finalized_scopes); + $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); + $responseType->setAccessToken($access_token); + + $refresh_token = $this->issueRefreshToken($access_token); + + if ($refresh_token !== null) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request)); + $responseType->setRefreshToken($refresh_token); + } + + return $responseType; + } + + /** + * @throws OAuthServerException + */ + protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface + { + $type = $this->userType($request, $client); + + $number = $this->getRequestParameter('number', $request); + $verification_code = $this->getRequestParameter('verification_code', $request); + + if (blank($number)) { + throw OAuthServerException::invalidRequest('number'); + } + + if (blank($verification_code)) { + throw OAuthServerException::invalidRequest('verification_code'); + } + + try { + $user = app(AuthenticateMobileNumber::class)($type, [ + 'number' => (string) $number, + 'country_code' => $this->getRequestParameter('country_code', $request), + 'verification_code' => (string) $verification_code, + 'verification_code_id' => $this->getRequestParameter('verification_code_id', $request), + 'code' => $this->getRequestParameter('code', $request), + 'recovery_code' => $this->getRequestParameter('recovery_code', $request), + ]); + } catch (ValidationException $exception) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request)); + + $errors = $exception->errors(); + $parameter = (string) array_key_first($errors); + + throw OAuthServerException::invalidRequest($parameter, $errors[$parameter][0] ?? null); + } + + return new UserEntity((string) $user->getAuthIdentifier()); + } + + /** + * The user type whose guard uses the client's provider + * + * @throws OAuthServerException + */ + protected function userType(ServerRequestInterface $request, ClientEntityInterface $client): UserType + { + $provider = (property_exists($client, 'provider') ? $client->provider : null) + ?: config('auth.guards.api.provider') + ?: 'users'; + + foreach (UserTypes::all() as $type) { + if (config("auth.guards.{$type->guardName()}.provider") === $provider) { + return $type; + } + } + + throw OAuthServerException::invalidClient($request); + } + + protected function supportsGrantType(ClientEntityInterface $client, string $grantType): bool + { + // Clients that can use the password grant can use this one + return parent::supportsGrantType($client, $grantType) + || ($grantType === $this->getIdentifier() && parent::supportsGrantType($client, 'password')); + } +} diff --git a/src/Policies/MobileNumberPolicy.php b/src/Policies/MobileNumberPolicy.php new file mode 100644 index 0000000..27b1b5b --- /dev/null +++ b/src/Policies/MobileNumberPolicy.php @@ -0,0 +1,68 @@ + action, the permission being `{action}_{type}_mobile_numbers` */ + public const array ABILITIES = [ + 'mobile-verification.update' => 'update', + 'mobile-verification.unlock' => 'unlock', + 'mobile-verification.remove' => 'remove', + ]; + + /** English descriptions of the permissions, used as translation keys */ + public const array DESCRIPTIONS = [ + 'update' => 'Change the mobile numbers of :resource', + 'unlock' => 'Unlock the locked mobile numbers of :resource', + 'remove' => 'Remove the mobile numbers of :resource', + ]; + + public function update(Model $user, UserType $type, ?Model $account = null): bool + { + return $this->allows($user, 'update', $type); + } + + public function unlock(Model $user, UserType $type, ?Model $account = null): bool + { + return $this->allows($user, 'unlock', $type); + } + + public function remove(Model $user, UserType $type, ?Model $account = null): bool + { + return $this->allows($user, 'remove', $type); + } + + /** + * The permission group: the plural of the type (`users`, `admins`), as javaabu/auth uses + */ + public static function group(UserType $type): string + { + return str($type->name())->plural()->snake()->toString(); + } + + public static function permission(string $action, UserType $type): string + { + return "{$action}_" . str($type->name())->snake()->toString() . '_mobile_numbers'; + } + + protected function allows(Model $user, string $action, UserType $type): bool + { + return $user->can(static::permission($action, $type)); + } +} diff --git a/src/Routing/MobileApiRoutes.php b/src/Routing/MobileApiRoutes.php new file mode 100644 index 0000000..57dc590 --- /dev/null +++ b/src/Routing/MobileApiRoutes.php @@ -0,0 +1,37 @@ + 'api'])` + * registers `POST {prefix}/mobile/code` and `POST {prefix}/mobile/token` + */ +class MobileApiRoutes +{ + /** + * @param array{ + * prefix?: string, + * middleware?: string|array + * } $options + */ + public static function register(Router $router, ?string $type = null, array $options = []): void + { + $user_type = $type ? UserTypes::get($type) : UserTypes::default(); + $controller = MobileVerification::config('controllers.api'); + + $router->group([ + 'prefix' => $options['prefix'] ?? 'api', + 'as' => "{$user_type->routeNamePrefix()}api.mobile.", + 'middleware' => (array) ($options['middleware'] ?? ['api']), + UserTypeRegistry::ROUTE_ACTION => $user_type->name(), + ], function (Router $router) use ($controller) { + $router->post('mobile/code', [$controller, 'code'])->middleware('throttle:mobile-verification.send')->name('code'); + $router->post('mobile/token', [$controller, 'token'])->middleware('throttle:mobile-verification.verify')->name('token'); + }); + } +} diff --git a/src/Routing/MobileNumberActionRoutes.php b/src/Routing/MobileNumberActionRoutes.php new file mode 100644 index 0000000..8147c1c --- /dev/null +++ b/src/Routing/MobileNumberActionRoutes.php @@ -0,0 +1,44 @@ + + * } $options + */ + public static function register(Router $router, string $type, array $options = []): void + { + $user_type = UserTypes::get($type); + $group = MobileNumberPolicy::group($user_type); + $controller = MobileVerification::config('controllers.mobile_number_actions'); + $name = rtrim($options['name'] ?? "{$group}.mobile-number", '.'); + + $router->group([ + 'prefix' => $options['prefix'] ?? $group, + 'as' => "{$name}.", + 'middleware' => (array) ($options['middleware'] ?? ['web']), + self::ROUTE_ACTION => $user_type->name(), + ], function (Router $router) use ($controller) { + $router->put('{account}/mobile-number', [$controller, 'update'])->name('update'); + $router->post('{account}/mobile-number/unlock', [$controller, 'unlock'])->name('unlock'); + $router->delete('{account}/mobile-number', [$controller, 'destroy'])->name('destroy'); + }); + } +} diff --git a/src/Routing/MobileNumberRoutes.php b/src/Routing/MobileNumberRoutes.php new file mode 100644 index 0000000..e05216c --- /dev/null +++ b/src/Routing/MobileNumberRoutes.php @@ -0,0 +1,57 @@ + 'settings'])` + */ +class MobileNumberRoutes +{ + /** + * @param array{ + * prefix?: string, + * middleware?: string|array, + * domain?: string|null, + * confirm_password?: bool + * } $options + */ + public static function register(Router $router, ?string $type = null, array $options = []): void + { + $user_type = $type ? UserTypes::get($type) : UserTypes::default(); + $controller = MobileVerification::config('controllers.mobile_number'); + + $middleware = [ + ...(array) ($options['middleware'] ?? ['web']), + "auth.type:{$user_type->name()}", + ]; + + if ($options['confirm_password'] ?? false) { + $middleware[] = "auth.password.confirm:{$user_type->name()}"; + } + + $attributes = [ + 'prefix' => $options['prefix'] ?? 'settings', + 'as' => $user_type->routeNamePrefix(), + 'middleware' => $middleware, + UserTypeRegistry::ROUTE_ACTION => $user_type->name(), + ]; + + if (! empty($options['domain'])) { + $attributes['domain'] = $options['domain']; + } + + $router->group($attributes, function (Router $router) use ($controller) { + $router->get('mobile-number', [$controller, 'edit'])->name('mobile-number'); + $router->post('mobile-number', [$controller, 'store'])->middleware('throttle:mobile-verification.send')->name('mobile-number.store'); + $router->get('mobile-number/verify', [$controller, 'verify'])->name('mobile-number.verify'); + $router->post('mobile-number/verify', [$controller, 'confirm'])->middleware('throttle:mobile-verification.verify')->name('mobile-number.verify.store'); + $router->post('mobile-number/resend', [$controller, 'resend'])->middleware('throttle:mobile-verification.send')->name('mobile-number.resend'); + }); + } +} diff --git a/src/Rules/IsValidCountryCode.php b/src/Rules/IsValidCountryCode.php index c38b3b3..3095213 100644 --- a/src/Rules/IsValidCountryCode.php +++ b/src/Rules/IsValidCountryCode.php @@ -3,17 +3,22 @@ namespace Javaabu\MobileVerification\Rules; use Closure; -use Javaabu\MobileVerification\MobileVerification; use Illuminate\Contracts\Validation\ValidationRule; +use Javaabu\MobileVerification\MobileVerification; +/** + * One of `mobile-verification.allowed_country_codes`, with or without the prefix + */ class IsValidCountryCode implements ValidationRule { public function validate(string $attribute, mixed $value, Closure $fail): void { - $value = MobileVerification::normalizeNumber($value); - $allowed_country_codes = config('mobile-verification.allowed_country_codes'); - if (! in_array($value, $allowed_country_codes)) { - $fail(trans('mobile-verification::strings.validation.country_code.invalid')); + if ($value === null || $value === '') { + return; + } + + if (! is_scalar($value) || ! in_array(MobileVerification::normalizeCountryCode((string) $value, false), MobileVerification::allowedCountryCodes(), true)) { + $fail(__('The country code is not allowed.')); } } } diff --git a/src/Rules/IsValidMobileNumber.php b/src/Rules/IsValidMobileNumber.php index 95c5fe2..4bb2a48 100644 --- a/src/Rules/IsValidMobileNumber.php +++ b/src/Rules/IsValidMobileNumber.php @@ -4,50 +4,62 @@ use Closure; use Illuminate\Contracts\Validation\DataAwareRule; -use Javaabu\MobileVerification\MobileVerification; use Illuminate\Contracts\Validation\ValidationRule; -use Javaabu\MobileVerification\Contracts\IsANumberFormatValidator; +use Illuminate\Database\Eloquent\Model; +use Javaabu\Auth\UserType; +use Javaabu\MobileVerification\Contracts\ValidatesNumberFormat; +use Javaabu\MobileVerification\MobileVerification; +/** + * A mobile number in the right format for its country code (read from the `country_code` input), + * optionally one that is, or isn't yet, a user's verified number: + * + * ```php + * 'number' => ['required', 'string', (new IsValidMobileNumber('user'))->notRegistered()->ignore($user)], + * ``` + */ class IsValidMobileNumber implements DataAwareRule, ValidationRule { - protected bool|null $should_be_registered_number = null; - protected bool $can_send_otp = false; - protected string $country_code; - protected string $ignore_user_id; + protected ?bool $should_be_registered = null; + + protected ?string $country_code = null; + protected int|string|null $ignore_user_id = null; + + /** + * @param UserType|string|null $user_type a javaabu/auth user type, or a model's morph class with `$is_morph_class` + */ public function __construct( - protected string $user_type, + protected UserType|string|null $user_type = null, protected string $country_code_input_name = 'country_code', - ) { - } + protected bool $is_morph_class = false, + ) {} public function registered(): static { - return $this->setShouldBeRegisteredNumber(true); - } + $this->should_be_registered = true; - public function notRegistered(): static - { - return $this->setShouldBeRegisteredNumber(false); + return $this; } - public function setShouldBeRegisteredNumber(?bool $value = null): static + public function notRegistered(): static { - $this->should_be_registered_number = $value; + $this->should_be_registered = false; return $this; } - public function canSendOtp(): static + public function setShouldBeRegisteredNumber(?bool $value): static { - $this->can_send_otp = true; + $this->should_be_registered = $value; return $this; } public function setCountryCode(?string $country_code): static { - $this->country_code = $country_code ?: MobileVerification::defaultCountryCode(); + $this->country_code = MobileVerification::normalizeCountryCode($country_code); + return $this; } @@ -56,78 +68,67 @@ public function getCountryCode(): string return $this->country_code ?? MobileVerification::defaultCountryCode(); } - public function ignore(string $user_id): static + /** + * Let a user keep their own number + */ + public function ignore(Model|int|string|null $user): static { - $this->ignore_user_id = $user_id; + $this->ignore_user_id = $user instanceof Model ? $user->getKey() : $user; return $this; } - public function getIgnoreUserId(): ?string - { - return $this->ignore_user_id ?? null; - } - public function validate(string $attribute, mixed $value, Closure $fail): void { + if (! is_scalar($value)) { + $fail(__('The :attribute is not a valid mobile number.')); - $value = MobileVerification::normalizeNumber($value); - - if (empty($value)) { // delegate required validation to Laravel return; } - /* @var IsANumberFormatValidator $mobile_number_format_validator */ - $mobile_number_format_validator = config('mobile-verification.mobile_number_format_validator'); - if (! (new $mobile_number_format_validator())->handle($this->getCountryCode(), $value)) { - $fail(trans('mobile-verification::strings.validation.number.invalid', ['attribute' => $attribute])); - } + $number = MobileVerification::normalizeNumber((string) $value); - if ((! $this->can_send_otp) && is_null($this->should_be_registered_number)) { + if ($number === '') { return; } - $model_class = MobileVerification::mobileNumberModel(); - $mobile_number = $model_class::query() - ->when($this->getIgnoreUserId(), function ($query) { - $query->where('user_id', '!=', $this->ignore_user_id); // todo : test - }) - ->hasPhoneNumber($this->getCountryCode(), $value, $this->user_type) - ->first(); + /** @var ValidatesNumberFormat $format */ + $format = app(ValidatesNumberFormat::class); - if ($mobile_number && $mobile_number->user_id && ! $mobile_number->user) { - $fail(trans('mobile-verification::strings.validation.number.soft_deleted', ['attribute' => $attribute])); - } + if (! $format->passes($this->getCountryCode(), $number)) { + $fail(__('The :attribute is not a valid mobile number.')); - if ($this->should_be_registered_number) { - if (empty($mobile_number) || empty($mobile_number->user_id)) { - $fail(trans('mobile-verification::strings.validation.number.doesnt-exist', ['attribute' => $attribute])); - } + return; } - if (! is_null($this->should_be_registered_number) && ! $this->should_be_registered_number) { - if ($mobile_number && $mobile_number->user_id) { - $fail(trans('mobile-verification::strings.validation.number.exists', ['attribute' => $attribute])); - } + if ($this->should_be_registered === null || $this->user_type === null) { + return; } - if ($mobile_number && $this->can_send_otp) { - if (! $mobile_number->can_request_code) { - $time = seconds_to_human_readable(now()->diffInSeconds($mobile_number->attempts_expiry_at)); - $fail(trans('mobile-verification::strings.validation.number.locked', ['time' => $time])); - } + $morph_class = $this->is_morph_class && is_string($this->user_type) + ? $this->user_type + : MobileVerification::morphClassFor($this->user_type); - if ($mobile_number->was_sent_recently) { - $resend_verification_code_in_seconds = $mobile_number->resend_verification_code_in ?? 0; + $owned = MobileVerification::mobileNumberModel()::query() + ->hasPhoneNumberWithOwner($this->getCountryCode(), $number, $morph_class) + ->when($this->ignore_user_id !== null, fn ($query) => $query->where('user_id', '!=', $this->ignore_user_id)) + ->exists(); - $fail(trans('mobile-verification::strings.validation.number.recently_sent', ['time' => seconds_to_human_readable($resend_verification_code_in_seconds)])); - } + if ($this->should_be_registered && ! $owned) { + $fail(__('There is no account with this mobile number.')); + } + + if (! $this->should_be_registered && $owned) { + $fail(__('This mobile number is already registered.')); } } public function setData(array $data): static { - $this->setCountryCode($data[$this->country_code_input_name] ?? null); + $this->setCountryCode(isset($data[$this->country_code_input_name]) && is_scalar($data[$this->country_code_input_name]) + ? (string) $data[$this->country_code_input_name] + : null); + return $this; } } diff --git a/src/Rules/IsValidVerificationCode.php b/src/Rules/IsValidVerificationCode.php deleted file mode 100644 index c715e63..0000000 --- a/src/Rules/IsValidVerificationCode.php +++ /dev/null @@ -1,129 +0,0 @@ -getNumber()) { - return; - } - - $mobile_number = MobileNumber::query() - ->hasPhoneNumber($this->getCountryCode(), $this->getNumber(), $this->user_type) - ->first(); - - if (! $mobile_number) { - $fail(trans('mobile-verification::strings.validation.verification_code.invalid')); - - return; - } - - if ($this->shouldVerifyVerificationCode() - && ! ( - $this->getVerificationCodeId() - && $this->getVerificationCodeId() == $mobile_number->verification_code_id - ) - ) { - $fail(trans('mobile-verification::strings.validation.verification_code.invalid')); - - return; - } - - if ($mobile_number->is_locked) { - $time = seconds_to_human_readable(now()->diffInSeconds($mobile_number->attempts_expiry_at)); - $fail(trans('mobile-verification::strings.validation.verification_code.locked', ['time' => $time])); - } - - if ($mobile_number->is_verification_code_expired) { - $fail(trans('mobile-verification::strings.validation.verification_code.expired')); - } - - if (! $mobile_number->verifyVerificationCode($value, $this->should_reset_attempts)) { - $fail(trans('mobile-verification::strings.validation.verification_code.invalid')); - } - } - - public function shouldVerifyVerificationCode(): bool - { - return $this->verify_verification_code_id; - } - - public function dontVerifyVerficationCodeId(): static - { - $this->verify_verification_code_id = false; - return $this; - } - - public function setCountryCode(?string $country_code): void - { - $this->country_code = $country_code ?: MobileVerification::defaultCountryCode(); - } - - public function getCountryCode(): string - { - return $this->country_code ?? MobileVerification::defaultCountryCode(); - } - - public function setNumber(?string $number): void - { - $this->number = $number; - } - - public function setVerificationCodeId(?string $verification_code_id): void - { - $this->verification_code_id = $verification_code_id; - } - - public function getVerificationCodeId(): ?string - { - return $this->verification_code_id; - } - - public function getNumber(): ?string - { - return $this->number; - } - - public function shouldResetAttempts(): static - { - return $this->setShouldResetAttempts(true); - } - - public function setShouldResetAttempts(bool $reset): static - { - $this->should_reset_attempts = $reset; - - return $this; - } - - public function setData(array $data): static - { - $this->setCountryCode($data[$this->country_code_input_name] ?? null); - $this->setNumber($data[$this->number_input_name] ?? null); - $this->setVerificationCodeId($data[$this->verification_code_id_input_name] ?? null); - - return $this; - } -} diff --git a/src/Stencil/MobileRegistrationForm.php b/src/Stencil/MobileRegistrationForm.php new file mode 100644 index 0000000..85dac0c --- /dev/null +++ b/src/Stencil/MobileRegistrationForm.php @@ -0,0 +1,40 @@ +id('mobile-registration-form') + ->submit(__('Create account')); + } + + protected function definition(): array + { + return [ + TextInput::make('name') + ->label(__('Name')) + ->required() + ->maxLength(255) + ->autocomplete('name') + ->thaana(fn () => app()->getLocale() === 'dv'), + + TextInput::make('email') + ->label(__('Email address')) + ->email() + ->maxLength(255) + ->unique() + ->autocomplete('email') + ->helperText(__('Optional')), + ]; + } +} diff --git a/src/Support/MobileNumberFormatValidator.php b/src/Support/MobileNumberFormatValidator.php deleted file mode 100644 index 9253fd6..0000000 --- a/src/Support/MobileNumberFormatValidator.php +++ /dev/null @@ -1,23 +0,0 @@ -name()}.{$purpose->value}.{$suffix}"; + } + + /** + * @param array $extra + */ + public static function put(Request $request, UserType $type, Purposes $purpose, MobileNumber $mobile_number, array $extra = []): void + { + $request->session()->put(static::key($type, $purpose), [ + ...$extra, + 'id' => $mobile_number->getKey(), + 'verification_code_id' => $mobile_number->verification_code_id, + ]); + } + + /** @return array|null */ + public static function get(Request $request, UserType $type, Purposes $purpose): ?array + { + $pending = $request->session()->get(static::key($type, $purpose)); + + return is_array($pending) && isset($pending['id']) ? $pending : null; + } + + public static function mobileNumber(Request $request, UserType $type, Purposes $purpose): ?MobileNumber + { + $pending = static::get($request, $type, $purpose); + + if (! $pending) { + return null; + } + + return MobileVerification::mobileNumberModel()::query() + ->whereKey($pending['id']) + ->where('user_type', MobileVerification::morphClassFor($type)) + ->first(); + } + + public static function forget(Request $request, UserType $type, Purposes $purpose): void + { + $request->session()->forget(static::key($type, $purpose)); + } + + /** + * Remember that the browser verified a number, e.g. before the registration details + */ + public static function markVerified(Request $request, UserType $type, Purposes $purpose, MobileNumber $mobile_number): void + { + static::forget($request, $type, $purpose); + + $request->session()->put(static::key($type, $purpose, 'verified'), [ + 'id' => $mobile_number->getKey(), + 'verified_at' => now()->getTimestamp(), + ]); + } + + /** + * The number the browser verified, within `verification_code_validity` minutes + */ + public static function verifiedMobileNumber(Request $request, UserType $type, Purposes $purpose): ?MobileNumber + { + $verified = $request->session()->get(static::key($type, $purpose, 'verified')); + + if (! is_array($verified) || ! isset($verified['id'], $verified['verified_at'])) { + return null; + } + + $expires_at = (int) $verified['verified_at'] + ((int) MobileVerification::config('verification_code_validity', 10) * 60); + + if ($expires_at <= now()->getTimestamp()) { + return null; + } + + return MobileVerification::mobileNumberModel()::query() + ->whereKey($verified['id']) + ->where('user_type', MobileVerification::morphClassFor($type)) + ->first(); + } + + public static function forgetVerified(Request $request, UserType $type, Purposes $purpose): void + { + $request->session()->forget(static::key($type, $purpose, 'verified')); + } +} diff --git a/src/Support/Strings.php b/src/Support/Strings.php new file mode 100644 index 0000000..edc498d --- /dev/null +++ b/src/Support/Strings.php @@ -0,0 +1,53 @@ + 'Log in with a password instead', + 'change_number' => 'Change number', + 'code' => 'Code', + 'code_help' => 'We sent a :length digit code to :number. It expires in :minutes minutes.', + 'continue' => 'Continue', + 'country_code' => 'Country code', + 'create_account' => 'Create account', + 'current_number' => 'Your mobile number is :number.', + 'have_account' => 'Already have an account?', + 'log_in' => 'Log in', + 'log_in_with_mobile' => 'Log in with your mobile number', + 'mobile_number' => 'Mobile number', + 'mobile_number_help' => 'We will send you a code by SMS.', + 'new_mobile_number' => 'New mobile number', + 'no_account' => "Don't have an account?", + 'no_number' => 'You have not verified a mobile number yet.', + 'register_details_help' => 'Your number :number is verified. Tell us a little about yourself.', + 'register_details_title' => 'Your details', + 'register_with_mobile' => 'Sign up with your mobile number', + 'remember_me' => 'Remember me', + 'resend_code' => 'Send a new code', + 'resend_in' => 'You can ask for a new code in :seconds seconds.', + 'send_code' => 'Send code', + 'settings_mobile_title' => 'Mobile number', + 'settings_mobile_help' => 'Your mobile number is used to log in and for SMS notifications.', + 'sign_up' => 'Sign up', + 'verify' => 'Verify', + ]; + + /** @return array */ + public static function translated(): array + { + $strings = []; + + foreach (self::ALL as $key => $english) { + // Placeholders stay for the page to fill + $strings[$key] = __($english); + } + + return $strings; + } +} diff --git a/src/Support/VerificationCodeGenerator.php b/src/Support/VerificationCodeGenerator.php index 6181771..ca17034 100644 --- a/src/Support/VerificationCodeGenerator.php +++ b/src/Support/VerificationCodeGenerator.php @@ -2,15 +2,18 @@ namespace Javaabu\MobileVerification\Support; -use Carbon\Carbon; -use Illuminate\Support\Str; -use Illuminate\Database\Eloquent\Model; -use Javaabu\MobileVerification\Contracts\IsVerificationCodeGenerator; +use Javaabu\MobileVerification\Contracts\GeneratesVerificationCodes; +use Javaabu\MobileVerification\MobileVerification; -class VerificationCodeGenerator implements IsVerificationCodeGenerator +/** + * Random numeric codes of `mobile-verification.code_length` digits, from a cryptographically secure source + */ +class VerificationCodeGenerator implements GeneratesVerificationCodes { - public function handle(): string + public function generate(): string { - return str_pad(rand(0, 999999), 6, 0, STR_PAD_LEFT); + $length = max(4, (int) MobileVerification::config('code_length', 6)); + + return str_pad((string) random_int(0, (10 ** $length) - 1), $length, '0', STR_PAD_LEFT); } } diff --git a/src/Support/VerificationCodeUpdater.php b/src/Support/VerificationCodeUpdater.php deleted file mode 100644 index 14021ec..0000000 --- a/src/Support/VerificationCodeUpdater.php +++ /dev/null @@ -1,33 +0,0 @@ -verificationCodeGenerator = app($verification_code_generator_class); - } - - public function handle(Model $mobile_number): string - { - $verification_code = $this->verificationCodeGenerator->handle(); - - $mobile_number->attempts = 0; - $mobile_number->verification_code = $verification_code; - $mobile_number->verification_code_created_at = Carbon::now(); - $mobile_number->verification_code_id = Str::uuid(); - $mobile_number->save(); - - return $verification_code; - } -} diff --git a/src/Traits/HasRequestMobileNumber.php b/src/Traits/HasRequestMobileNumber.php deleted file mode 100644 index 3d6d5bb..0000000 --- a/src/Traits/HasRequestMobileNumber.php +++ /dev/null @@ -1,58 +0,0 @@ -input($this->getMobileNumberInputKey()); - $number = MobileVerification::normalizeNumber($number); - $country_code = $request->input($this->getCountryCodeInputKey()) ?: MobileVerification::defaultCountryCode(); - - $model_class = MobileVerification::mobileNumberModel(); - - $mobile_number = $model_class::query() - ->hasPhoneNumber($country_code, $number, $this->getUserType($request)) - ->first(); - - if (! $create || $mobile_number) { - return $mobile_number; - } - - $mobile_number = new $model_class(); - $mobile_number->number = $number; - $mobile_number->country_code = $country_code; - $mobile_number->user_type = $this->getUserType($request); - $mobile_number->user_id = null; - $mobile_number->save(); - - return $mobile_number; - } - - public function getMobileNumberInputKey(): string - { - return 'number'; - } - - public function getCountryCodeInputKey(): string - { - return 'country_code'; - } - - public function getVerificationCodeInputKey(): string - { - return 'verification_code'; - } - - public function getVerificationCodeIdInputKey(): string - { - return 'verification_code_id'; - } -} diff --git a/src/Traits/HasUserType.php b/src/Traits/HasUserType.php deleted file mode 100644 index 10107ce..0000000 --- a/src/Traits/HasUserType.php +++ /dev/null @@ -1,15 +0,0 @@ -getUserClass($request)))->getMorphClass(); - } -} diff --git a/src/Traits/InteractsWithMobileNumbers.php b/src/Traits/InteractsWithMobileNumbers.php deleted file mode 100644 index 5a9410f..0000000 --- a/src/Traits/InteractsWithMobileNumbers.php +++ /dev/null @@ -1,279 +0,0 @@ -isForceDeleting()) { - $user->phone?->delete(); - } - }); - } - - /** - * A user has one mobile number - * @return MorphOne - */ - public function phone(): MorphOne - { - return $this->morphOne(MobileVerification::mobileNumberModel(), 'user'); - } - - /** - * Get full mobile number attribute - * @return string - */ - public function getFormattedMobileNumberAttribute(): ?string - { - return $this->phone ? $this->phone->formatted_number : null; - } - - /** - * Get mobile number country code attribute - * - * @return string - */ - public function getMobileNumberCountryCodeAttribute(): ?string - { - return $this->phone ? $this->phone->country_code : null; - } - - /** - * Get mobile number attribute - * - * @return string - */ - public function getMobileNumberAttribute(): ?string - { - return $this->phone ? $this->phone->number : null; - } - - /** - * Get short mobile number attribute - * - * @return string - */ - public function getShortMobileNumberAttribute(): ?string - { - return $this->phone ? $this->phone->short_formatted_number : null; - } - - /** - * Should verify phone scope - */ - public function scopeShouldVerifyPhone(Builder $query): Builder - { - return $query; - } - - /** - * Check if the current user state allows the user to be verified - * - * @return bool - */ - public function getShouldVerifyPhoneAttribute(): bool - { - return true; - } - - /** - * Needs phone verification scope - */ - public function scopeNeedsPhoneVerification(Builder $query): Builder - { - return $query->shouldVerifyPhone() - ->phoneUnverified(); - } - - /** - * Check if needs phone verification - * @return bool - */ - public function getNeedsPhoneVerificationAttribute(): bool - { - return $this->should_verify_phone && ! $this->phone_verified; - } - - /** - * Phone unverified scope - */ - public function scopePhoneUnverified(Builder $query): Builder - { - return $query->whereDoesNotHave('phone'); - } - - /** - * Phone verified scope - */ - public function scopePhoneVerified(Builder $query): Builder - { - return $query->whereHas('phone'); - } - - /** - * Check if phone verified - * - * @return bool - */ - public function getPhoneVerifiedAttribute(): bool - { - return ! empty($this->phone); - } - - public function hasVerifiedMobile(): bool - { - return $this->phone_verified; - } - - /** - * Clear phones - */ - public function clearPhones() - { - $model = MobileVerification::mobileNumberModel(); - - $model::where('user_id', $this->getKey()) - ->where('user_type', $this->getMorphClass()) - ->update([ - 'user_id' => null, - ]); - } - - /** - * Update the mobile number - * - * @param string $number - * @param string $country_code - */ - public function updateMobileNumber(string $number, string $country_code = '') - { - $model = MobileVerification::mobileNumberModel(); - - $phone = $model::blankPhone($number, $country_code, $this->getMorphClass()); - - if ($phone) { - $this->updatePhone($phone); - } - } - - /** - * Update the phone - * - * @param MobileNumber $phone - * @param null $causer - */ - public function updatePhone($phone, $causer = null) - { - $model = MobileVerification::mobileNumberModel(); - - if (! ($phone instanceof $model)) { - $phone = $model::whereId($phone)->first(); - } - - $old_phone = $this->formatted_mobile_number; - $new_phone = $phone->formatted_number; - - // first clear all current phones - $this->clearPhones(); - - // assign the new phone - $phone->clearVerificationCode(); - $phone->user()->associate($this); - $phone->save(); - - if ($old_phone != $new_phone) { - event(new MobileNumberUpdated($old_phone, $new_phone, $this, $causer)); - } - } - - /** - * Find mobile number without using country code. - * By default would assume it's using default country code, - * unless the number starts with a plus +. - * - * @param $query - * @param $mobile_no - * @return mixed - */ - public function scopeHasShortMobileNumber(Builder $query, string $mobile_no): Builder - { - return $query->whereHas('phone', function ($query) use ($mobile_no) { - $query->hasNumber($mobile_no); - }); - } - - /** - * Has mobile number scope - * - * @param $query - * @param $mobile_no - * @param null $country_code - * @return mixed - */ - public function scopeHasMobileNumber(Builder $query, string $mobile_no, ?string $country_code = null): Builder - { - if (empty($country_code)) { - $country_code = MobileVerification::defaultCountryCode(); - } - - return $query->whereHas('phone', function ($query) use ($mobile_no, $country_code) { - $query->where('number', $mobile_no) - ->where('country_code', $country_code); - }); - } - - /** - * Has phone scope - * @param $query - * @param $search - * @return mixed - */ - public function scopeHasPhone(Builder $query, string $search): Builder - { - return $query->whereHas('phone', function ($query) use ($search) { - $query->search($search); - }); - } - - /** - * The number to send SMSs to - * - * @return string - */ - public function routeNotificationForSms(): string|array|null - { - return optional($this->phone)->routeNotificationForSms(); - } - - /** - * Get email verification url prefix - * - * @return string - */ - public function phoneVerificationUrlPrefix(): string - { - return $this->url_prefix; - } - -} diff --git a/src/Traits/LoginsWithMobileNumber.php b/src/Traits/LoginsWithMobileNumber.php deleted file mode 100644 index af56e01..0000000 --- a/src/Traits/LoginsWithMobileNumber.php +++ /dev/null @@ -1,76 +0,0 @@ -applyMiddlewares(); - } - - public function applyMiddlewares(): void - { - $this->middleware('guest:' . $this->getGuardName()); - } - - public function showVerificationCodeRequestForm(Request $request): View - { - if ($mobile_number = $this->getSessionMobileNumber($request)) { - return $this->showVerificationCodeForm($request, $mobile_number); - } - - return view($this->getVerificationCodeRequestFormView()); - } - - public function doAfterVerificationCodeVerified(MobileNumber $mobile_number, Request $request): mixed - { - /* @var HasMobileNumber $user */ - $user = $mobile_number->user; - - $this->guard()->login($user); - $request->session()->regenerate(); - - return $user; - } - - public function flashVerificationCodeSuccessMessage(MobileNumber $mobile_number, Request $request, $data = null): void - { - // - } - - public function getVerificationCodeSmsNotification(string $verification_code, MobileNumber $mobile_number): SmsNotification - { - $notification_class = config('mobile-verification.notifications.login'); - - return new $notification_class($verification_code, $mobile_number?->user?->name); - } - - public function mustBeARegisteredMobileNumber(array $request_data): ?bool - { - return true; - } - - public function getSessionMobileNumberKey(): string - { - return 'mobile_number_to_login'; - } - - public function shouldAddMobileNumberToSession(): bool - { - return true; - } -} diff --git a/src/Traits/RegistersWithMobileNumber.php b/src/Traits/RegistersWithMobileNumber.php deleted file mode 100644 index 9f69d67..0000000 --- a/src/Traits/RegistersWithMobileNumber.php +++ /dev/null @@ -1,143 +0,0 @@ -applyMiddlewares(); - } - - public function showRegistrationForm() - { - $verified_code_data = session()->get($this->getVerifiedCodeDataSessionKey()); - - if ($verified_code_data) { - return view($this->getRegistrationFormView(), $verified_code_data); - } - - // if the mobile number is missing - // redirect to get a new code - return redirect() - ->action([self::class, 'showVerificationCodeRequestForm']) - ->withErrors([ - 'verification_code' => 'missing', - ]); - } - - public function verificationCodeSuccessRedirectUrl(): string - { - return action([self::class, 'showRegistrationForm']); - } - - protected function validator(array $data) - { - $rules = $this->getRegistrationVerificationCodeValidationRules(request()); - - return Validator::make($data, array_merge($rules, $this->getRegisterFieldsValidationRules(request()))); - } - - public function getRegistrationVerificationCodeValidationRules(Request $request): array - { - return $this->getVerificationCodeValidationRules(request()); - } - - public function create(array $data) - { - $user = $this->createUser($data); - - $mobile_number = $this->getMobileNumberFromRequest(request()); - - $user->updatePhone($mobile_number); - - session()->forget($this->getVerifiedCodeDataSessionKey()); - - return $user; - } - - public function showVerificationCodeRequestForm(Request $request): View - { - if ($mobile_number = $this->getSessionMobileNumber($request)) { - return $this->showVerificationCodeForm($request, $mobile_number); - } - - return view($this->getVerificationCodeRequestFormView()); - } - - public function doAfterVerificationCodeVerified(MobileNumber $mobile_number, Request $request): mixed - { - // - } - - public function getVerificationCodeSmsNotification(string $verification_code, MobileNumber $mobile_number): SmsNotification - { - $notification_class = config('mobile-verification.notifications.register'); - return new $notification_class($verification_code, $mobile_number?->user?->name); - } - - public function mustBeARegisteredMobileNumber(array $request_data): ?bool - { - return false; - } - - public function getSessionMobileNumberKey(): string - { - return 'mobile_number_to_register'; - } - - public function shouldAddMobileNumberToSession(): bool - { - return true; - } - - public function shouldResetVerificationAttemptsOnSuccess(): bool - { - return false; - } - - public function applyMiddlewares(): void - { - $this->middleware('guest:' . $this->getGuardName()); - } - - public function getGuard(): StatefulGuard - { - return $this->guard(); - } - - public function determinePathForRedirectUsing(): \Javaabu\Auth\User - { - return (new ($this->getUserClass())); - } - - public function userClass(): string - { - return $this->getUserClass(); - } - - public function redirectPath() - { - return with($this->determinePathForRedirectUsing())->homeUrl(); - } -} diff --git a/src/Traits/SendsVerificationCode.php b/src/Traits/SendsVerificationCode.php deleted file mode 100644 index e66bfae..0000000 --- a/src/Traits/SendsVerificationCode.php +++ /dev/null @@ -1,115 +0,0 @@ -getVerificationCodeRequestFormView()); - } - - public function requestVerificationCode(Request $request) - { - $validator = $this->getVerificationCodeRequestValidator($request); - - if ($validator->fails()) { - return $this->sendFailedVerificationCodeRequestResponse($request, $validator); - } - - $should_create_mobile_number = ! $this->mustBeARegisteredMobileNumber($request->all()); - $mobile_number = $this->getMobileNumberFromRequest($request, $should_create_mobile_number); - $verification_code = $mobile_number->generateVerificationCode(); - - $this->sendVerificationCodeSms($verification_code, $mobile_number); - - if ($this->shouldAddMobileNumberToSession()) { - $this->setSessionMobileNumber($mobile_number, $request); - } - - return $this->sendSuccessfulVerificationCodeRequestResponse( - $mobile_number, - $request - ); - } - - public function sendSuccessfulVerificationCodeRequestResponse(MobileNumber $mobile_number, Request $request) - { - if (expects_json($request)) { - return response()->json($mobile_number->verificationCodeResponseData()); - } - - return $this->redirectAfterVerificationCodeRequest($mobile_number, $request); - } - - public function redirectAfterVerificationCodeRequest(MobileNumber $mobile_number, Request $request) - { - return back()->with([ - 'success' => true, - 'mobile_number' => $mobile_number, - ]); - } - - public function sendFailedVerificationCodeRequestResponse(Request $request, ?Validator $validator = null) - { - if (expects_json($request)) { - return response()->json([ - 'errors' => $validator->errors(), - ], 422); - } - - return back()->withErrors($validator); - } - - public function getVerificationCodeRequestValidator(Request $request): Validator - { - return ValidatorFacade::make($request->all(), $this->getVerificationCodeRequestValidationRules($request)); - } - - public function getVerificationCodeRequestValidationRules(Request $request): array - { - - $valid_mobile_number_rule = (new IsValidMobileNumber( - $this->getUserType($request), - $this->getCountryCodeInputKey() - )) - ->canSendOtp() - ->setShouldBeRegisteredNumber($this->mustBeARegisteredMobileNumber($request->all())); - - $rules = [ - $this->getMobileNumberInputKey() => ['required', 'string', $valid_mobile_number_rule], - $this->getCountryCodeInputKey() => ['nullable', 'string', new IsValidCountryCode()], - ]; - - if ($this->enableReCaptcha()) { - $rules[recaptchaFieldName()] = recaptchaRuleName(); - } - - return $rules; - } - - public function sendVerificationCodeSms(string $verification_code, MobileNumber $mobile_number): void - { - $mobile_number->notify($this->getVerificationCodeSmsNotification($verification_code, $mobile_number)); - } - - public function enableReCaptcha(): bool - { - return config('mobile-verification.use_recaptcha'); - } - - public function shouldAddMobileNumberToSession(): bool - { - return false; - } -} diff --git a/src/Traits/UpdatesMobileNumber.php b/src/Traits/UpdatesMobileNumber.php deleted file mode 100644 index 002123f..0000000 --- a/src/Traits/UpdatesMobileNumber.php +++ /dev/null @@ -1,75 +0,0 @@ -getSessionMobileNumber($request)) { - return $this->showVerificationCodeForm($request, $mobile_number); - } - - return view($this->getVerificationCodeRequestFormView()); - } - - public function doAfterVerificationCodeVerified(MobileNumber $mobile_number, Request $request): mixed - { - /* @var HasMobileNumber $user */ - $user = $request->user(); - $user->updatePhone($mobile_number); - - return $user; - } - - public function getVerificationCodeSmsNotification(string $verification_code, MobileNumber $mobile_number): SmsNotification - { - $notification_class = config('mobile-verification.notifications.update'); - - return new $notification_class($verification_code, $mobile_number); - } - - public function mustBeARegisteredMobileNumber(array $request_data): ?bool - { - return false; - } - - public function getSessionMobileNumberKey(): string - { - return 'mobile_to_update'; - } - - public function shouldAddMobileNumberToSession(): bool - { - return true; - } - - public function getVerificationCodeSuccessMessage(MobileNumber $mobile_number, Request $request, $data = null): string - { - return trans('mobile-verification::messages.mobile_number_updated', [ - 'mobile_number' => $mobile_number->formatted_number, - ]); - } - - public function getVerificationCodeSuccessMessageTitle(MobileNumber $mobile_number, Request $request, $data = null): string - { - return trans('mobile-verification::messages.mobile_number_updated_title'); - } - - public function redirectAfterVerificationCodeRequest(MobileNumber $mobile_number, Request $request): \Illuminate\View\View - { - return $this->showVerificationCodeForm($request, $mobile_number); - } -} diff --git a/src/Traits/UsesGuard.php b/src/Traits/UsesGuard.php deleted file mode 100644 index 643a8ba..0000000 --- a/src/Traits/UsesGuard.php +++ /dev/null @@ -1,15 +0,0 @@ -guard($this->getGuardName()); - } -} diff --git a/src/Traits/UsesSessionMobileNumber.php b/src/Traits/UsesSessionMobileNumber.php deleted file mode 100644 index aa3c7ac..0000000 --- a/src/Traits/UsesSessionMobileNumber.php +++ /dev/null @@ -1,32 +0,0 @@ -session()->pull($this->getSessionMobileNumberKey()); - } - - public function getSessionMobileNumber(Request $request): ?MobileNumber - { - $mobileNumberId = $this->getSessionMobileNumberId($request); - - if (! $mobileNumberId) { - return null; - } - - return MobileVerification::findMobileNumberById($mobileNumberId); - } - - public function setSessionMobileNumber(MobileNumber $mobile_number, Request $request): void - { - $request->session()->put($this->getSessionMobileNumberKey(), $mobile_number->id); - } - -} diff --git a/src/Traits/VerifiesVerificationCode.php b/src/Traits/VerifiesVerificationCode.php deleted file mode 100644 index 26135dc..0000000 --- a/src/Traits/VerifiesVerificationCode.php +++ /dev/null @@ -1,143 +0,0 @@ -getVerificationCodeValidator($request); - $mobile_number = $this->getMobileNumberFromRequest($request); - - if ($validator->fails()) { - return $this->sendFailedCodeVerificationResponse($request, $validator, $mobile_number); - } - - $data = $this->doAfterVerificationCodeVerified($mobile_number, $request); - - return $this->sendSuccessfulCodeVerificationResponse($mobile_number, $request, $data); - } - - public function showVerificationCodeForm(Request $request, ?MobileNumber $mobile_number = null): View - { - return view($this->getVerificationCodeFormView(), [ - 'mobile_number' => $mobile_number, - ]); - } - - public function sendSuccessfulCodeVerificationResponse(MobileNumber $mobile_number, Request $request, $data = null) - { - if (expects_json($request)) { - return response()->json([ - 'verified' => true, - 'mobile_number' => $mobile_number->verificationCodeResponseData(), - ]); - } - - $this->flashVerificationCodeSuccessMessage($mobile_number, $request, $data); - - return $this->redirectAfterSuccessfulCodeVerification($mobile_number, $request, $data); - } - - public function getVerifiedCodeDataSessionKey(): string - { - return 'verified_code_data'; - } - - public function redirectAfterSuccessfulCodeVerification(MobileNumber $mobile_number, Request $request, $data = null) - { - return redirect() - ->to($this->verificationCodeSuccessRedirectUrl()) - ->with([ - 'success' => true, - $this->getVerifiedCodeDataSessionKey() => $data ?: [ - $this->getMobileNumberInputKey() => $request->input($this->getMobileNumberInputKey()), - $this->getCountryCodeInputKey() => $request->input($this->getCountryCodeInputKey()), - $this->getVerificationCodeInputKey() => $request->input($this->getVerificationCodeInputKey()), - $this->getVerificationCodeIdInputKey() => $request->input($this->getVerificationCodeIdInputKey()), - ] - ]); - } - - public function flashVerificationCodeSuccessMessage(MobileNumber $mobile_number, Request $request, $data = null): void - { - flash_push('alerts', [ - 'text' => $this->getVerificationCodeSuccessMessage($mobile_number, $request, $data), - 'type' => 'success', - 'title' => $this->getVerificationCodeSuccessMessageTitle($mobile_number, $request, $data), - ]); - } - - public function getVerificationCodeSuccessMessage(MobileNumber $mobile_number, Request $request, $data = null): string - { - return trans('mobile-verification::messages.verification_code_verified'); - } - - public function getVerificationCodeSuccessMessageTitle(MobileNumber $mobile_number, Request $request, $data = null): string - { - return trans('mobile-verification::messages.verification_code_verified_title'); - } - - public function sendFailedCodeVerificationResponse(Request $request, ?Validator $validator = null, ?MobileNumber $mobile_number = null) - { - if (expects_json($request)) { - return response()->json([ - 'errors' => $validator->errors(), - 'mobile_number' => $mobile_number?->verificationCodeResponseData(), - ], 422); - } - - if ($this->getVerificationCodeFormView()) { - return $this->showVerificationCodeForm($request, $mobile_number)->withErrors($validator); - } - - return back()->withErrors($validator); - } - - public function getVerificationCodeValidator(Request $request): Validator - { - return ValidatorFacade::make($request->all(), $this->getVerificationCodeValidationRules($request)); - } - - public function getVerificationCodeValidationRules(Request $request): array - { - $valid_mobile_number_rule = (new IsValidMobileNumber( - $this->getUserType($request), - $this->getCountryCodeInputKey() - )) - ->setShouldBeRegisteredNumber($this->mustBeARegisteredMobileNumber($request->all())); - - $valid_verification_code_rule = (new IsValidVerificationCode( - $this->getUserType($request), - $this->getCountryCodeInputKey(), - $this->getMobileNumberInputKey(), - $this->getVerificationCodeIdInputKey() - )) - ->setShouldResetAttempts($this->shouldResetVerificationAttemptsOnSuccess()); - - $rules = [ - $this->getMobileNumberInputKey() => ['required', 'string', $valid_mobile_number_rule], - $this->getCountryCodeInputKey() => ['nullable', 'string', new IsValidCountryCode()], - $this->getVerificationCodeInputKey() => ['required', 'string', $valid_verification_code_rule], - $this->getVerificationCodeIdInputKey() => ['required', 'string'], - ]; - - return $rules; - } - - public function shouldResetVerificationAttemptsOnSuccess(): bool - { - return true; - } -} diff --git a/src/Views/ViewRenderer.php b/src/Views/ViewRenderer.php new file mode 100644 index 0000000..bc56272 --- /dev/null +++ b/src/Views/ViewRenderer.php @@ -0,0 +1,93 @@ +viewStack(); + } + + /** + * @param string $page e.g. `login` or `settings.mobile-number` + * @param array $data + * @param string $layout `auth` for guest pages, `account` for signed-in pages + */ + public function render(UserType $type, string $page, array $data = [], string $layout = 'auth'): View|Responsable + { + $data = [...$this->sharedData($type), ...$data]; + + if ($this->viewStack($type) === self::INERTIA) { + return $this->renderInertia($page, $data); + } + + return view("mobile-verification::{$page}", [ + ...$data, + 'user_type' => $type, + 'layout' => $type->layoutName($layout), + 'layout_section' => config('javaabu-auth.layout_section', 'content'), + ]); + } + + /** + * The Inertia page name for a page: `settings.mobile-number` → `mobile-verification::Settings/MobileNumber` + */ + public function inertiaPage(string $page): string + { + $segments = array_map(fn (string $segment) => str($segment)->studly()->toString(), explode('.', $page)); + + return 'mobile-verification::' . implode('/', $segments); + } + + /** @return array */ + public function sharedData(UserType $type): array + { + return [ + ...$this->page_data->shared($type), + 'mobile' => [ + 'country_codes' => MobileVerification::countryCodeOptions(), + 'default_country_code' => MobileVerification::defaultCountryCode(), + 'number_prefix' => MobileVerification::numberPrefix(), + 'code_length' => (int) MobileVerification::config('code_length', 6), + ], + ]; + } + + /** + * @param array $data + */ + protected function renderInertia(string $page, array $data): Responsable + { + if (! class_exists(Inertia::class)) { + throw new RuntimeException('Install inertiajs/inertia-laravel to use the inertia views, or set mobile-verification.views to blade.'); + } + + return Inertia::render($this->inertiaPage($page), [ + ...$data, + // javaabu/auth's strings too, for its AccountLayout + 'strings' => [...$this->page_data->strings(), ...Strings::translated()], + ]); + } +} diff --git a/tests/Factories/AdminFactory.php b/tests/Factories/AdminFactory.php new file mode 100644 index 0000000..0ea0f81 --- /dev/null +++ b/tests/Factories/AdminFactory.php @@ -0,0 +1,54 @@ + + */ +class AdminFactory extends Factory +{ + protected $model = Admin::class; + + public function definition(): array + { + return [ + 'name' => 'އަޙްމަދު ޢަލީ', + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => 'password', + 'status' => UserStatuses::APPROVED, + ]; + } + + /** + * With a verified mobile number + */ + public function withMobileNumber(string $number = '7771234', string $country_code = '960'): static + { + return $this->afterCreating(function (Admin $user) use ($number, $country_code) { + MobileNumber::factory()->forUser($user)->create([ + 'number' => $number, + 'country_code' => $country_code, + ]); + }); + } + + public function withTwoFactor(string $secret = 'JBSWY3DPEHPK3PXP'): static + { + return $this->state([ + 'two_factor_secret' => encrypt($secret), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1', 'recovery-code-2'])), + 'two_factor_confirmed_at' => now(), + ]); + } + + public function banned(): static + { + return $this->state(['status' => UserStatuses::BANNED]); + } +} diff --git a/tests/Factories/UserFactory.php b/tests/Factories/UserFactory.php new file mode 100644 index 0000000..ef4b868 --- /dev/null +++ b/tests/Factories/UserFactory.php @@ -0,0 +1,54 @@ + + */ +class UserFactory extends Factory +{ + protected $model = User::class; + + public function definition(): array + { + return [ + 'name' => 'އަޙްމަދު ޢަލީ', + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => 'password', + 'status' => UserStatuses::APPROVED, + ]; + } + + /** + * With a verified mobile number + */ + public function withMobileNumber(string $number = '7771234', string $country_code = '960'): static + { + return $this->afterCreating(function (User $user) use ($number, $country_code) { + MobileNumber::factory()->forUser($user)->create([ + 'number' => $number, + 'country_code' => $country_code, + ]); + }); + } + + public function withTwoFactor(string $secret = 'JBSWY3DPEHPK3PXP'): static + { + return $this->state([ + 'two_factor_secret' => encrypt($secret), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1', 'recovery-code-2'])), + 'two_factor_confirmed_at' => now(), + ]); + } + + public function banned(): static + { + return $this->state(['status' => UserStatuses::BANNED]); + } +} diff --git a/tests/Feature/Controllers/Api/ApiTokenLoginControllerTest.php b/tests/Feature/Controllers/Api/ApiTokenLoginControllerTest.php deleted file mode 100644 index d6a4b69..0000000 --- a/tests/Feature/Controllers/Api/ApiTokenLoginControllerTest.php +++ /dev/null @@ -1,125 +0,0 @@ -get('/api/protected') - ->assertStatus(302) - ->assertRedirect(route('login')); - } - - - #[Test] - public function can_obtain_an_access_token_using_a_valid_verification_code() - { - $this->withoutExceptionHandling(); - $user = User::factory()->create(); - MobileNumber::unguard(); - - $mobile_number = MobileNumber::create([ - 'number' => '7528222', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $verification_code = $mobile_number->generateVerificationCode(); - - $this->assertDatabaseHas('mobile_numbers', [ - 'number' => '7528222', - 'country_code' => '960', - 'user_id' => $user->id, - 'user_type' => 'user', - ]); - - $grantClient = $this->app - ->make(ClientRepository::class) - ->createPasswordGrantClient('Test', 'users'); - - Config::set('auth.guards.api.provider', 'users'); - Config::set('auth.providers.users.model', User::class); - - $response = $this->postJson('/oauth/token', [ - 'grant_type' => 'mobile', - 'client_id' => $grantClient->id, - 'client_secret' => $grantClient->plainSecret, - 'number' => '7528222', - 'country_code' => '960', - 'verification_code' => $verification_code, - ]); - - $response->assertStatus(200); - $response->assertJsonStructure(['token_type', 'access_token', 'refresh_token', 'expires_in']); - } - - public function test_authenticated_user_can_visit_protected_routes() - { - $user = User::factory()->create(); - $access_token = $this->getAccessToken($user); - - $response = $this->json('GET', '/api/protected', [], [ - 'Authorization' => 'Bearer ' . $access_token, - ]); - - $response->assertStatus(200); - $response->assertSeeText('Protected route'); - } - - public function getAccessToken(?User $user = null): string - { - $user ??= User::factory()->create(); - MobileNumber::unguard(); - - $mobile_number = MobileNumber::create([ - 'number' => '7528222', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $verification_code = $mobile_number->generateVerificationCode(); - - $grantClient = $this->app->make(ClientRepository::class) - ->createPasswordGrantClient('Test', 'users'); - - Config::set('auth.guards.api.provider', 'users'); - Config::set('auth.guards.api.driver', 'passport'); - Config::set('auth.providers.users.model', User::class); - - $response = $this->postJson('/oauth/token', [ - 'grant_type' => 'mobile', - 'client_id' => $grantClient->id, - 'client_secret' => $grantClient->plainSecret, - 'number' => '7528222', - 'country_code' => '960', - 'verification_code' => $verification_code, - ]); - - return $response->json('access_token'); - } - -} diff --git a/tests/Feature/Controllers/Web/LoginControllerTest.php b/tests/Feature/Controllers/Web/LoginControllerTest.php deleted file mode 100644 index 37edc4e..0000000 --- a/tests/Feature/Controllers/Web/LoginControllerTest.php +++ /dev/null @@ -1,130 +0,0 @@ -get('/mobile-verification/login') - ->assertSee("Verification Code Request Form"); - } - - public function test_can_see_the_verification_code_form_if_mobile_number_is_present_in_session() // done - { - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => null, - ]); - - $this->withSession(['mobile_number_to_login' => $mobile_number->id]) - ->get('/mobile-verification/login') - ->assertSee("Enter Verification Code"); - } - - public function test_can_send_a_post_request_to_login_a_user_with_a_mobile_number() // done - { - $this->get('/mobile-numbers'); - - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $this->post('/mobile-verification/login', [ - 'number' => '7326655', - ])->assertSessionHasNoErrors(); - } - - public function test_if_the_number_provided_is_not_registered_then_guest_is_redirected_back_with_errors() - { - $response = $this->post('mobile-verification/login', [ - 'number' => '7326655', - ]); - $response->assertRedirect(); - $response->assertSessionHasErrors(['number']); - } - - /* @throws JsonException */ - public function test_if_the_number_provided_is_valid_then_system_sends_an_otp_to_the_number() - { - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $this->post('mobile-verification/login', [ - 'number' => '7326655', - ]) - ->assertSessionHasNoErrors(); - - Notification::assertSentTo( - [$mobile_number], - LoginVerificationTokenNotification::class - ); - } - - /* - * LOGIN TESTS - * */ - - #[Test] - public function an_unauthorized_user_cannot_visit_auth_protected_routes() - { - $this->get('/mobile-verification/protected') - ->assertStatus(302) - ->assertRedirect('/login'); - } - - #[Test] - // An existing user can login using a token - public function an_existing_user_can_login_using_a_token() - { - $user = User::factory()->create([ - 'name' => 'John Doe', - 'email' => 'john@example.com', - ]); - - $mobile_number = MobileNumber::factory()->create([ - 'user_type' => 'user', - 'user_id' => $user->id, - 'number' => '7528222', - 'country_code' => MobileVerification::defaultCountryCode(), - ]); - - $verification_code = $mobile_number->generateVerificationCode(); - - $this->patch('/mobile-verification/login', [ - 'number' => '7528222', - 'verification_code' => $verification_code, - 'verification_code_id' => $mobile_number->verification_code_id?->toString(), - ])->assertSessionHasNoErrors(); - - $this->get('mobile-verification/protected') - ->assertSee('Protected route'); - } -} diff --git a/tests/Feature/Controllers/Web/MobileNumberUpdateControllerTest.php b/tests/Feature/Controllers/Web/MobileNumberUpdateControllerTest.php deleted file mode 100644 index 813fd1a..0000000 --- a/tests/Feature/Controllers/Web/MobileNumberUpdateControllerTest.php +++ /dev/null @@ -1,131 +0,0 @@ -get('/mobile-verification/update') - ->assertSee("Verification Code Request Form"); - } - - public function test_can_see_the_update_verification_code_form_if_mobile_number_is_present_in_session() // done - { - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => null, - ]); - - $this->withSession(['mobile_to_update' => $mobile_number->id]) - ->get('/mobile-verification/update') - ->assertSee("Enter Verification Code"); - } - - public function test_can_send_a_post_request_to_update_a_user_with_a_mobile_number() // done - { - $this->get('/mobile-numbers'); - - $this->post('/mobile-verification/update', [ - 'number' => '7326655', - ])->assertSessionHasNoErrors(); - } - - public function test_if_the_number_provided_for_update_is_already_registered_then_user_is_redirected_back_with_errors() - { - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $response = $this->post('mobile-verification/update', [ - 'number' => '7326655', - ]); - $response->assertRedirect(); - $response->assertSessionHasErrors(['number']); - } - - /* @throws JsonException */ - public function test_if_the_number_provided_for_update_is_valid_then_system_sends_an_otp_to_the_number() - { - $this->post('mobile-verification/update', [ - 'number' => '7326655', - ]) - ->assertSessionHasNoErrors(); - - $mobile_number = MobileNumber::where('number', '7326655')->first(); - - Notification::assertSentTo( - [$mobile_number], - MobileNumberVerificationToken::class - ); - } - - /* - * UPDATE MOBILE NUMBER TESTS - * */ - /* @throws JsonException */ - public function test_user_can_update_mobile_number_with_valid_token() - { - $this->withoutExceptionHandling(); - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7528222', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $new_mobile_number = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => null, - ]); - - $this->actingAs($user); - - $verification_code = $new_mobile_number->generateVerificationCode(); - - $this->patch('mobile-verification/update', [ - 'number' => '7326655', - 'verification_code' => $verification_code, - 'verification_code_id' => $new_mobile_number->verification_code_id->toString(), - ]) - ->assertSessionHasNoErrors() - ->assertRedirect(); - - $this->assertDatabaseHas('mobile_numbers', [ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $this->assertDatabaseHas('mobile_numbers', [ - 'number' => '7528222', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => null, - ]); - } -} diff --git a/tests/Feature/Controllers/Web/RegisterControllerTest.php b/tests/Feature/Controllers/Web/RegisterControllerTest.php deleted file mode 100644 index 37316fa..0000000 --- a/tests/Feature/Controllers/Web/RegisterControllerTest.php +++ /dev/null @@ -1,125 +0,0 @@ -get('/mobile-verification/register') - ->assertSee("Verification Code Request Form"); - } - - public function test_can_see_the_verification_code_form_if_mobile_number_is_present_in_session() // done - { - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => null, - ]); - - $this->withSession(['mobile_number_to_register' => $mobile_number->id]) - ->get('/mobile-verification/register') - ->assertSee("Enter Verification Code"); - } - - #[Test] - public function if_the_number_provided_is_already_registered_then_the_user_is_redirected_back_with_an_error() - { - $user = User::factory()->create(); - $mobileNumber = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $response = $this->post('mobile-verification/register', [ - 'number' => $mobileNumber->number, - ]); - - $response->assertSessionHasErrors(['number']); - - Notification::assertNotSentTo( - [$mobileNumber], - MobileNumberVerificationToken::class - ); - } - - #[Test] - public function it_can_send_registration_verification_code_if_the_number_is_not_taken() - { - $response = $this->post('mobile-verification/register', [ - 'number' => '7528222' - ]) ->assertSessionHasNoErrors(); - - $this->assertDatabaseHas('mobile_numbers', [ - 'number' => '7528222', - 'country_code' => MobileVerification::defaultCountryCode(), - 'user_type' => 'user', - 'user_id' => null, - ]); - - $mobile_number = MobileNumber::where('number', '7528222')->first(); - - Notification::assertSentTo( - [$mobile_number], - RegisterVerificationTokenNotification::class - ); - } - - /* - * REGISTRATION TESTS - * */ - - // it can register a user if the token is correct - #[Test] - public function it_can_register_a_user_if_the_token_is_correct() - { - $mobileNumber = MobileNumber::factory()->create([ - 'user_type' => 'user', - 'user_id' => null, - 'number' => '7528222', - ]); - - $verification_code = $mobileNumber->generateVerificationCode(); - - $this->patch('mobile-verification/register', [ - 'number' => '7528222', - 'name' => 'John Doe', - 'email' => 'admin@example.com', - 'verification_code' => $verification_code, - 'verification_code_id' => $mobileNumber->verification_code_id?->toString(), - ]) - ->assertSessionHasNoErrors(); - - $this->assertDatabaseHas('users', [ - 'name' => 'John Doe', - 'email' => 'admin@example.com', - ]); - - $user_id = User::max('id'); - - $this->assertDatabaseHas('mobile_numbers', [ - 'number' => '7528222', - 'user_id' => $user_id, - 'user_type' => 'user', - ]); - } -} diff --git a/tests/Feature/MobileLoginTest.php b/tests/Feature/MobileLoginTest.php new file mode 100644 index 0000000..56dc241 --- /dev/null +++ b/tests/Feature/MobileLoginTest.php @@ -0,0 +1,326 @@ +get('/login') + ->assertOk() + ->assertSee('data-login-method="mobile"', false) + ->assertSee(url('/login/mobile')); + + $this->get('/admin/login') + ->assertOk() + ->assertSee(url('/admin/login/mobile')); + } + + #[Test] + public function the_mobile_login_page_renders(): void + { + $this->get('/login/mobile') + ->assertOk() + ->assertSee('data-mobile-page="login"', false) + ->assertSee('name="number"', false) + ->assertSee('+960') + ->assertSee(url('/register/mobile')); + + // Admins can't register + $this->get('/admin/login/mobile') + ->assertOk() + ->assertDontSee(url('/admin/register/mobile')); + } + + #[Test] + public function users_log_in_with_a_code(): void + { + Event::fake([VerificationCodeSent::class]); + + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234', 'remember' => '1']) + ->assertRedirect('/login/mobile/verify') + ->assertSessionHas('status'); + + Event::assertDispatched(VerificationCodeSent::class, fn (VerificationCodeSent $event) => $event->purpose === Purposes::LOGIN); + + $code = $this->sentCode(); + + $this->get('/login/mobile/verify') + ->assertOk() + ->assertSee('data-mobile-page="verify"', false) + ->assertSee('+9607771234') + ->assertSee('autocomplete="one-time-code"', false); + + $this->post('/login/mobile/verify', ['verification_code' => $code]) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); + $this->assertGuest('web_admin'); + $this->assertNotNull($user->fresh()->getRememberToken()); + $this->assertFalse($user->phone->fresh()->hasVerificationCode()); + } + + #[Test] + public function admins_log_in_on_their_own_guard(): void + { + $admin = Admin::factory()->withMobileNumber('7771234')->create(); + User::factory()->withMobileNumber('7779999')->create(); + + $this->post('/admin/login/mobile', ['number' => '7771234']) + ->assertRedirect('/admin/login/mobile/verify'); + + $this->post('/admin/login/mobile/verify', ['verification_code' => $this->sentCode()]) + ->assertRedirect('/admin'); + + $this->assertAuthenticatedAs($admin, 'web_admin'); + $this->assertGuest('web'); + } + + #[Test] + public function a_user_number_does_not_log_in_an_admin(): void + { + User::factory()->withMobileNumber('7771234')->create(); + + $this->from('/admin/login/mobile') + ->post('/admin/login/mobile', ['number' => '7771234']) + ->assertRedirect('/admin/login/mobile') + ->assertSessionHasErrors(['number' => 'There is no account with this mobile number.']); + + Notification::assertNothingSent(); + } + + #[Test] + public function the_number_must_be_valid(): void + { + $this->from('/login/mobile') + ->post('/login/mobile', ['number' => '1234']) + ->assertSessionHasErrors('number'); + + $this->post('/login/mobile', ['number' => '7771234', 'country_code' => '44']) + ->assertSessionHasErrors('country_code'); + + $this->post('/login/mobile', [])->assertSessionHasErrors('number'); + } + + #[Test] + public function a_wrong_code_counts_and_locks_the_number(): void + { + Event::fake([MobileNumberLocked::class]); + + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + $code = $this->sentCode(); + $wrong = $code === '000000' ? '111111' : '000000'; + + $this->from('/login/mobile/verify') + ->post('/login/mobile/verify', ['verification_code' => $wrong]) + ->assertRedirect('/login/mobile/verify') + ->assertSessionHasErrors(['verification_code' => 'The code is incorrect. You have 4 attempts left.']); + + foreach (range(1, 3) as $attempt) { + $this->post('/login/mobile/verify', ['verification_code' => $wrong]); + } + + $this->post('/login/mobile/verify', ['verification_code' => $wrong]) + ->assertSessionHasErrors(['verification_code' => 'This number is locked after too many wrong codes. Try again in 30 minutes.']); + + Event::assertDispatched(MobileNumberLocked::class); + + // Even the right code doesn't work now + $this->post('/login/mobile/verify', ['verification_code' => $code]) + ->assertSessionHasErrors('verification_code'); + + $this->assertGuest('web'); + + // Nor can a new code be sent + $this->travel(1)->minutes(); + + $this->post('/login/mobile', ['number' => '7771234']) + ->assertSessionHasErrors(['number' => 'This number is locked after too many wrong codes. Try again in 29 minutes.']); + } + + #[Test] + public function codes_expire(): void + { + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + $code = $this->sentCode(); + + $this->travel(11)->minutes(); + + $this->post('/login/mobile/verify', ['verification_code' => $code]) + ->assertSessionHasErrors(['verification_code' => 'The code has expired. Ask for a new code.']); + + $this->assertGuest('web'); + } + + #[Test] + public function an_older_code_is_no_longer_valid_after_a_new_one_is_sent(): void + { + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + $first = $this->sentCode(); + + // Another browser asks for a code for the same number + $this->travel(31)->seconds(); + $other = MobileNumber::query()->first(); + $other->generateVerificationCode(); + + $this->post('/login/mobile/verify', ['verification_code' => $first]) + ->assertSessionHasErrors(['verification_code' => 'This code is no longer valid. Ask for a new code.']); + } + + #[Test] + public function a_new_code_can_be_sent_after_the_resend_interval(): void + { + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + + $this->from('/login/mobile/verify') + ->post('/login/mobile/resend') + ->assertSessionHasErrors('verification_code'); + + $this->get('/login/mobile/verify')->assertSee('data-resend-in', false); + + $this->travel(31)->seconds(); + + $this->from('/login/mobile/verify') + ->post('/login/mobile/resend') + ->assertRedirect('/login/mobile/verify') + ->assertSessionHasNoErrors(); + + Notification::assertSentTimes(VerificationCodeNotification::class, 2); + + // The newest code logs in + $this->post('/login/mobile/verify', ['verification_code' => $this->lastCode()]) + ->assertRedirect('/dashboard'); + } + + #[Test] + public function the_code_page_needs_a_pending_number(): void + { + $this->get('/login/mobile/verify')->assertRedirect('/login/mobile'); + + $this->post('/login/mobile/verify', ['verification_code' => '123456']) + ->assertRedirect('/login/mobile') + ->assertSessionHasErrors('number'); + } + + #[Test] + public function two_factor_authentication_still_applies(): void + { + $user = User::factory()->withTwoFactor()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + + $this->post('/login/mobile/verify', ['verification_code' => $this->sentCode()]) + ->assertRedirect('/two-factor-challenge') + ->assertSessionHas(StartAuthenticatedSession::CHALLENGE_SESSION_KEY); + + $this->assertGuest('web'); + } + + #[Test] + public function the_two_factor_challenge_can_be_skipped(): void + { + config()->set('mobile-verification.skip_two_factor', true); + + $user = User::factory()->withTwoFactor()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + + $this->post('/login/mobile/verify', ['verification_code' => $this->sentCode()]) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); + } + + #[Test] + public function signed_in_users_are_sent_home(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($user, 'web')->get('/login/mobile')->assertRedirect('/dashboard'); + } + + #[Test] + public function it_answers_json(): void + { + User::factory()->withMobileNumber('7771234')->create(); + + $this->postJson('/login/mobile', ['number' => '7771234']) + ->assertOk() + ->assertJsonPath('formatted_number', '+9607771234') + ->assertJsonPath('is_registered', true) + ->assertJsonPath('expires_in', 600) + ->assertJsonStructure(['verification_code_id', 'resend_in', 'remaining_attempts']); + + $this->postJson('/login/mobile/resend') + ->assertStatus(429) + ->assertJsonValidationErrors('verification_code'); + + $this->postJson('/login/mobile', ['number' => '7770000']) + ->assertStatus(422) + ->assertJsonValidationErrors('number'); + + $this->postJson('/login/mobile/verify', ['verification_code' => $this->sentCode()]) + ->assertOk() + ->assertJsonPath('two_factor', false) + ->assertJsonPath('redirect', url('/dashboard')); + } + + #[Test] + public function requests_for_codes_are_rate_limited(): void + { + config()->set('mobile-verification.rate_limits.send', 2); + + $this->post('/login/mobile', ['number' => '7770001']); + $this->post('/login/mobile', ['number' => '7770002']); + $this->post('/login/mobile', ['number' => '7770003'])->assertStatus(429); + } + + #[Test] + public function extra_rules_can_guard_the_code_request(): void + { + config()->set('mobile-verification.request_code_rules', ['captcha' => ['required']]); + + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234'])->assertSessionHasErrors('captcha'); + + Notification::assertNothingSent(); + } + + protected function lastCode(): string + { + $codes = []; + + Notification::assertSentTo(MobileNumber::query()->first(), VerificationCodeNotification::class, function (VerificationCodeNotification $notification) use (&$codes) { + $codes[] = $notification->verification_code; + + return true; + }); + + return end($codes); + } +} diff --git a/tests/Feature/MobileNumberActionsTest.php b/tests/Feature/MobileNumberActionsTest.php new file mode 100644 index 0000000..f34fb95 --- /dev/null +++ b/tests/Feature/MobileNumberActionsTest.php @@ -0,0 +1,197 @@ +set('permissions.guards', ['web_admin']); + } + + protected function defineRoutes($router): void + { + $this->registerRoutes(); + + Route::mobileNumberActions('user', ['prefix' => 'admin/users', 'middleware' => ['web', 'auth.type:admin']]); + Route::mobileNumberActions('admin', ['prefix' => 'admin/admins', 'middleware' => ['web', 'auth.type:admin']]); + } + + protected function admin(array $permissions = []): Admin + { + $this->artisan('permissions:sync'); + + $admin = Admin::factory()->withMobileNumber('7770000')->create(); + + if ($permissions) { + $admin->givePermissionTo($permissions); + } + + return $admin; + } + + #[Test] + public function it_registers_permissions_for_each_user_type(): void + { + $this->assertTrue(Permissions::has('update_user_mobile_numbers')); + $this->assertTrue(Permissions::has('unlock_admin_mobile_numbers')); + $this->assertTrue(Permissions::has('remove_user_mobile_numbers')); + $this->assertSame('Change the mobile numbers of users', Permissions::description('update_user_mobile_numbers')); + $this->assertSame('users', Permissions::groupOf('update_user_mobile_numbers')->name); + } + + #[Test] + public function admins_need_the_permission(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($this->admin(), 'web_admin') + ->put("/admin/users/{$user->id}/mobile-number", ['number' => '9991234']) + ->assertForbidden(); + + $this->post("/admin/users/{$user->id}/mobile-number/unlock")->assertForbidden(); + $this->delete("/admin/users/{$user->id}/mobile-number")->assertForbidden(); + + $this->assertSame('+9607771234', $user->fresh()->formatted_mobile_number); + } + + #[Test] + public function guests_and_users_cannot_use_the_actions(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->put("/admin/users/{$user->id}/mobile-number", ['number' => '9991234'])->assertRedirect('/admin/login'); + + $this->actingAs($user, 'web') + ->put("/admin/users/{$user->id}/mobile-number", ['number' => '9991234']) + ->assertRedirect('/admin/login'); + } + + #[Test] + public function admins_change_a_users_number_without_a_code(): void + { + Event::fake([MobileNumberUpdated::class]); + + $user = User::factory()->withMobileNumber('7771234')->create(); + $admin = $this->admin(['update_user_mobile_numbers']); + + $this->actingAs($admin, 'web_admin') + ->from('/admin/users') + ->put("/admin/users/{$user->id}/mobile-number", ['number' => '9991234']) + ->assertRedirect('/admin/users') + ->assertSessionHas('status', 'The mobile number has been changed to +9609991234.'); + + $this->assertSame('+9609991234', $user->fresh()->formatted_mobile_number); + + Event::assertDispatched(MobileNumberUpdated::class, fn (MobileNumberUpdated $event) => $event->causer->is($admin)); + } + + #[Test] + public function the_number_must_be_free(): void + { + User::factory()->withMobileNumber('9991234')->create(); + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($this->admin(['update_user_mobile_numbers']), 'web_admin') + ->putJson("/admin/users/{$user->id}/mobile-number", ['number' => '9991234']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['number' => 'This mobile number is already registered.']); + + // Their own number is fine + $this->putJson("/admin/users/{$user->id}/mobile-number", ['number' => '7771234'])->assertOk(); + } + + #[Test] + public function admins_unlock_and_remove_numbers(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + $user->phone->forceFill(['attempts' => 5, 'verification_code_created_at' => now()])->save(); + + $this->assertTrue($user->phone->fresh()->isLocked()); + + $this->actingAs($this->admin(['unlock_user_mobile_numbers', 'remove_user_mobile_numbers']), 'web_admin') + ->postJson("/admin/users/{$user->id}/mobile-number/unlock") + ->assertOk() + ->assertJsonPath('message', 'The mobile number has been unlocked.'); + + $this->assertFalse($user->phone->fresh()->isLocked()); + + $this->deleteJson("/admin/users/{$user->id}/mobile-number")->assertOk(); + + $this->assertFalse($user->fresh()->hasVerifiedMobile()); + $this->assertSame(2, MobileNumber::query()->count()); + } + + #[Test] + public function permissions_are_per_user_type(): void + { + $other = Admin::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($this->admin(['update_user_mobile_numbers']), 'web_admin') + ->putJson("/admin/admins/{$other->id}/mobile-number", ['number' => '9991234']) + ->assertForbidden(); + } + + #[Test] + public function unknown_accounts_are_not_found(): void + { + $this->actingAs($this->admin(['update_user_mobile_numbers']), 'web_admin') + ->putJson('/admin/users/999/mobile-number', ['number' => '9991234']) + ->assertNotFound(); + } + + #[Test] + #[DefineEnvironment('useDenyAllPolicy')] + public function the_policy_can_be_replaced_in_config(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($this->admin(['update_user_mobile_numbers']), 'web_admin') + ->putJson("/admin/users/{$user->id}/mobile-number", ['number' => '9991234']) + ->assertForbidden(); + } + + #[Test] + public function abilities_can_be_redefined_on_the_gate(): void + { + Gate::define('mobile-verification.remove', fn () => true); + + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($this->admin(), 'web_admin') + ->deleteJson("/admin/users/{$user->id}/mobile-number") + ->assertOk(); + } + + #[Test] + #[DefineEnvironment('turnOffPermissions')] + public function permissions_can_be_turned_off(): void + { + $this->assertFalse(Permissions::has('update_user_mobile_numbers')); + } + + protected function useDenyAllPolicy($app): void + { + $app['config']->set('mobile-verification.policy', DenyAllMobileNumberPolicy::class); + } + + protected function turnOffPermissions($app): void + { + $app['config']->set('mobile-verification.register_permissions', false); + } +} diff --git a/tests/Feature/MobileNumberSettingsTest.php b/tests/Feature/MobileNumberSettingsTest.php new file mode 100644 index 0000000..9c95973 --- /dev/null +++ b/tests/Feature/MobileNumberSettingsTest.php @@ -0,0 +1,184 @@ +get('/settings/mobile-number')->assertRedirect('/login'); + $this->get('/admin/settings/mobile-number')->assertRedirect('/admin/login'); + } + + #[Test] + public function users_change_their_number_with_a_code(): void + { + Event::fake([MobileNumberUpdated::class]); + + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($user, 'web') + ->get('/settings/mobile-number') + ->assertOk() + ->assertSee('data-current-number', false) + ->assertSee('+9607771234'); + + $this->post('/settings/mobile-number', ['number' => '9991234']) + ->assertRedirect('/settings/mobile-number/verify'); + + $this->get('/settings/mobile-number/verify')->assertOk()->assertSee('+9609991234'); + + // Still the old number until the code is entered + $this->assertSame('+9607771234', $user->fresh()->formatted_mobile_number); + + $this->post('/settings/mobile-number/verify', ['verification_code' => $this->sentCode('+9609991234')]) + ->assertRedirect('/settings/mobile-number') + ->assertSessionHas('status', 'Your mobile number is now +9609991234.'); + + $this->assertSame('+9609991234', $user->fresh()->formatted_mobile_number); + + Event::assertDispatched(MobileNumberUpdated::class, fn (MobileNumberUpdated $event) => $event->old_number === '+9607771234' + && $event->causer->is($user)); + } + + #[Test] + public function the_number_of_another_user_cannot_be_taken(): void + { + User::factory()->withMobileNumber('9991234')->create(); + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($user, 'web') + ->post('/settings/mobile-number', ['number' => '9991234']) + ->assertSessionHasErrors(['number' => 'This mobile number is already registered.']); + + $this->post('/settings/mobile-number', ['number' => '7771234']) + ->assertSessionHasErrors(['number' => 'This is already your mobile number.']); + + Notification::assertNothingSent(); + } + + #[Test] + public function an_admin_can_use_a_number_a_user_has(): void + { + User::factory()->withMobileNumber('7771234')->create(); + $admin = Admin::factory()->create(); + + $this->actingAs($admin, 'web_admin') + ->post('/admin/settings/mobile-number', ['number' => '7771234']) + ->assertRedirect('/admin/settings/mobile-number/verify'); + + $this->post('/admin/settings/mobile-number/verify', ['verification_code' => $this->sentCodeTo($admin->getMorphClass())]) + ->assertRedirect('/admin/settings/mobile-number'); + + $this->assertSame('+9607771234', $admin->fresh()->formatted_mobile_number); + } + + #[Test] + public function users_without_a_number_must_verify_one_before_continuing(): void + { + $user = User::factory()->create(); + + $this->actingAs($user, 'web') + ->get('/dashboard') + ->assertRedirect('/settings/mobile-number'); + + $this->get('/settings/mobile-number') + ->assertOk() + ->assertSee('data-no-number', false); + + $this->getJson('/dashboard')->assertForbidden(); + + $this->post('/settings/mobile-number', ['number' => '7771234']); + $this->post('/settings/mobile-number/verify', ['verification_code' => $this->sentCode()]) + ->assertRedirect('/dashboard'); + + $this->get('/dashboard')->assertOk()->assertSee('user dashboard'); + } + + #[Test] + public function the_middleware_checks_the_users_of_its_type(): void + { + $admin = Admin::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($admin, 'web_admin')->get('/admin')->assertOk(); + } + + #[Test] + public function the_page_is_in_the_account_navigation_of_each_type(): void + { + config()->set('javaabu-auth.layouts', ['auth' => 'auth::layouts.auth', 'account' => 'auth::layouts.account']); + + $this->actingAs(User::factory()->withMobileNumber('7771234')->create(), 'web') + ->get('/settings/profile') + ->assertOk() + ->assertSee('href="' . url('/settings/mobile-number') . '"', false); + + $this->get('/settings/mobile-number') + ->assertOk() + ->assertSee('data-account-nav', false) + ->assertSee('aria-current="page"', false); + + $this->actingAs(Admin::factory()->withMobileNumber('7771234')->create(), 'web_admin') + ->get('/admin/settings/profile') + ->assertOk() + ->assertSee('href="' . url('/admin/settings/mobile-number') . '"', false); + } + + #[Test] + public function types_without_the_page_have_no_link(): void + { + config()->set('javaabu-auth.layouts', ['auth' => 'auth::layouts.auth', 'account' => 'auth::layouts.account']); + + $this->app['router']->setRoutes(new RouteCollection); + Route::authRoutes('user'); + $this->app['router']->getRoutes()->refreshNameLookups(); + + $this->actingAs(User::factory()->withMobileNumber('7771234')->create(), 'web') + ->get('/settings/profile') + ->assertOk() + ->assertDontSee('mobile-number', false); + } + + #[Test] + public function the_page_can_ask_for_the_password_first(): void + { + $this->app['router']->setRoutes(new RouteCollection); + Route::authRoutes('user'); + Route::mobileNumberRoutes('user', ['confirm_password' => true]); + $this->app['router']->getRoutes()->refreshNameLookups(); + + $this->actingAs(User::factory()->create(), 'web') + ->get('/settings/mobile-number') + ->assertRedirect('/confirm-password'); + } + + protected function sentCodeTo(string $user_type): string + { + $code = null; + + Notification::assertSentTo( + MobileNumber::query()->where('user_type', $user_type)->sole(), + VerificationCodeNotification::class, + function ($notification) use (&$code) { + $code = $notification->verification_code; + + return true; + }, + ); + + return $code; + } +} diff --git a/tests/Feature/MobileRegistrationTest.php b/tests/Feature/MobileRegistrationTest.php new file mode 100644 index 0000000..11ee4df --- /dev/null +++ b/tests/Feature/MobileRegistrationTest.php @@ -0,0 +1,190 @@ +get('/register/mobile') + ->assertOk() + ->assertSee('data-mobile-page="register"', false); + + $this->post('/register/mobile', ['number' => '7771234']) + ->assertRedirect('/register/mobile/verify'); + + $this->post('/register/mobile/verify', ['verification_code' => $this->sentCode()]) + ->assertRedirect('/register/mobile/details'); + + $this->get('/register/mobile/details') + ->assertOk() + ->assertSee('data-mobile-page="register-details"', false) + ->assertSee('+9607771234') + ->assertSee('name="name"', false) + ->assertSee('name="email"', false); + + $this->post('/register/mobile/details', ['name' => 'އަޙްމަދު ޢަލީ', 'email' => 'Ahmed@Example.com']) + ->assertRedirect('/dashboard'); + + $user = User::query()->sole(); + + $this->assertSame('އަޙްމަދު ޢަލީ', $user->name); + $this->assertSame('ahmed@example.com', $user->email); + $this->assertNull($user->password); + $this->assertSame('+9607771234', $user->formatted_mobile_number); + $this->assertAuthenticatedAs($user, 'web'); + + Event::assertDispatched(Registered::class); + } + + #[Test] + public function the_email_is_optional(): void + { + $this->post('/register/mobile', ['number' => '7771234']); + $this->post('/register/mobile/verify', ['verification_code' => $this->sentCode()]); + + $this->post('/register/mobile/details', ['name' => 'Ahmed'])->assertRedirect('/dashboard'); + + $this->assertNull(User::query()->sole()->email); + } + + #[Test] + public function the_details_are_validated(): void + { + User::factory()->create(['email' => 'taken@example.com']); + + $this->post('/register/mobile', ['number' => '7771234']); + $this->post('/register/mobile/verify', ['verification_code' => $this->sentCode()]); + + $this->from('/register/mobile/details') + ->post('/register/mobile/details', ['email' => 'taken@example.com']) + ->assertRedirect('/register/mobile/details') + ->assertSessionHasErrors(['name', 'email']); + + $this->assertGuest('web'); + } + + #[Test] + public function registered_numbers_cannot_sign_up_again(): void + { + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/register/mobile', ['number' => '7771234']) + ->assertSessionHasErrors(['number' => 'This mobile number is already registered.']); + + Notification::assertNothingSent(); + } + + #[Test] + public function a_number_taken_while_filling_in_the_details_is_refused(): void + { + $this->post('/register/mobile', ['number' => '7771234']); + $this->post('/register/mobile/verify', ['verification_code' => $this->sentCode()]); + + User::factory()->create()->updateMobileNumber('7771234'); + + $this->post('/register/mobile/details', ['name' => 'Ahmed']) + ->assertRedirect('/register/mobile') + ->assertSessionHasErrors('number'); + + $this->assertGuest('web'); + } + + #[Test] + public function the_details_page_needs_a_verified_number(): void + { + $this->get('/register/mobile/details')->assertRedirect('/register/mobile'); + + $this->post('/register/mobile', ['number' => '7771234']); + + // Sent, not verified + $this->get('/register/mobile/details')->assertRedirect('/register/mobile'); + $this->post('/register/mobile/details', ['name' => 'Ahmed'])->assertRedirect('/register/mobile'); + + $this->assertSame(0, User::query()->count()); + } + + #[Test] + public function the_verified_number_expires(): void + { + $this->post('/register/mobile', ['number' => '7771234']); + $this->post('/register/mobile/verify', ['verification_code' => $this->sentCode()]); + + $this->travel(11)->minutes(); + + $this->get('/register/mobile/details')->assertRedirect('/register/mobile'); + } + + #[Test] + public function types_without_registration_have_no_mobile_registration(): void + { + $this->get('/admin/register/mobile')->assertNotFound(); + } + + #[Test] + public function mobile_registration_can_be_turned_off(): void + { + config()->set('mobile-verification.registration', false); + + $this->refreshRoutes(); + + $this->get('/register/mobile')->assertNotFound(); + $this->get('/login/mobile')->assertOk()->assertDontSee(url('/register/mobile')); + } + + #[Test] + public function apps_change_the_form_and_how_users_are_created(): void + { + config()->set('mobile-verification.registration_form', RegistrationFormWithIdCard::class); + $this->app->bind(CreatesUsersFromMobileNumbers::class, CreateUserWithIdCard::class); + + $this->post('/register/mobile', ['number' => '7771234']); + $this->post('/register/mobile/verify', ['verification_code' => $this->sentCode()]); + + $this->get('/register/mobile/details')->assertSee('name="id_card"', false); + + $this->post('/register/mobile/details', ['name' => 'Ahmed', 'id_card' => 'bad']) + ->assertSessionHasErrors('id_card'); + + $this->post('/register/mobile/details', ['name' => 'Ahmed', 'id_card' => 'A123456']) + ->assertRedirect('/dashboard'); + + $this->assertSame('Ahmed (A123456)', User::query()->sole()->name); + } + + #[Test] + public function it_answers_json(): void + { + $this->postJson('/register/mobile', ['number' => '7771234']) + ->assertOk() + ->assertJsonPath('is_registered', false); + + $this->postJson('/register/mobile/verify', ['verification_code' => $this->sentCode()]) + ->assertOk() + ->assertJsonPath('verified', true) + ->assertJsonPath('redirect', url('/register/mobile/details')); + + $this->postJson('/register/mobile/details', ['name' => 'Ahmed'])->assertCreated(); + } + + protected function refreshRoutes(): void + { + $this->app['router']->setRoutes(new RouteCollection); + $this->registerRoutes(); + $this->app['router']->getRoutes()->refreshNameLookups(); + } +} diff --git a/tests/Feature/OverridesTest.php b/tests/Feature/OverridesTest.php new file mode 100644 index 0000000..f2d9c36 --- /dev/null +++ b/tests/Feature/OverridesTest.php @@ -0,0 +1,52 @@ +get('/login/mobile')->assertOk()->assertSee('custom mobile login for user'); + $this->get('/admin/login/mobile')->assertOk()->assertSee('custom mobile login for admin'); + } + + #[Test] + public function the_code_generator_can_be_rebound(): void + { + $this->app->bind(GeneratesVerificationCodes::class, fn () => new class implements GeneratesVerificationCodes + { + public function generate(): string + { + return '424242'; + } + }); + + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + + $this->assertSame('424242', $this->sentCode()); + } + + #[Test] + public function views_can_be_overridden_by_the_app(): void + { + $this->app['view']->getFinder()->prependNamespace('mobile-verification', __DIR__ . '/../views/overrides'); + + $this->get('/login/mobile')->assertOk()->assertSee('overridden mobile login'); + } + + protected function useCustomController($app): void + { + $app['config']->set('mobile-verification.controllers.login', CustomMobileLoginController::class); + } +} diff --git a/tests/Feature/PassportGrantTest.php b/tests/Feature/PassportGrantTest.php new file mode 100644 index 0000000..98f7a2e --- /dev/null +++ b/tests/Feature/PassportGrantTest.php @@ -0,0 +1,128 @@ +set('auth.providers.users.model', User::class); + $config->set('auth.guards.api', ['driver' => 'passport', 'provider' => 'users']); + + Passport::loadKeysFrom(static::keyPath()); + } + + protected function defineDatabaseMigrations(): void + { + parent::defineDatabaseMigrations(); + + $this->loadMigrationsFrom(__DIR__ . '/../../vendor/laravel/passport/database/migrations'); + } + + protected function defineRoutes($router): void + { + $this->registerRoutes(); + + Route::middleware(['api', 'auth:api'])->get('api/me', fn () => request()->user()->name); + } + + #[Test] + public function the_mobile_grant_issues_tokens(): void + { + User::factory()->withMobileNumber('7771234')->create(['name' => 'Ahmed']); + $client = app(ClientRepository::class)->createPasswordGrantClient('App', 'users'); + + $code_id = $this->postJson('/login/mobile', ['number' => '7771234'])->json('verification_code_id'); + + $response = $this->post('/oauth/token', [ + 'grant_type' => 'mobile', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'number' => '7771234', + 'verification_code' => $this->sentCode(), + 'verification_code_id' => $code_id, + ])->assertOk(); + + $this->assertNotEmpty($response->json('refresh_token')); + + $this->app['auth']->forgetGuards(); + + $this->withToken($response->json('access_token'))->getJson('/api/me')->assertOk()->assertSee('Ahmed'); + } + + #[Test] + public function a_wrong_code_is_refused(): void + { + User::factory()->withMobileNumber('7771234')->create(); + $client = app(ClientRepository::class)->createPasswordGrantClient('App', 'users'); + + $this->postJson('/login/mobile', ['number' => '7771234']); + + $this->post('/oauth/token', [ + 'grant_type' => 'mobile', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'number' => '7771234', + 'verification_code' => 'wrong', + ])->assertStatus(400)->assertJsonPath('error', 'invalid_request'); + } + + #[Test] + public function clients_of_providers_without_a_user_type_are_refused(): void + { + User::factory()->withMobileNumber('7771234')->create(); + $client = app(ClientRepository::class)->createPasswordGrantClient('App', 'customers'); + + $this->post('/oauth/token', [ + 'grant_type' => 'mobile', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'number' => '7771234', + 'verification_code' => '123456', + ])->assertStatus(401)->assertJsonPath('error', 'invalid_client'); + } + + protected static function keyPath(): string + { + if (static::$key_path) { + return static::$key_path; + } + + $path = sys_get_temp_dir() . '/javaabu-mobile-verification-passport-' . getmypid(); + + if (! is_dir($path)) { + mkdir($path); + } + + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $private); + + file_put_contents("{$path}/oauth-private.key", $private); + file_put_contents("{$path}/oauth-public.key", openssl_pkey_get_details($key)['key']); + chmod("{$path}/oauth-private.key", 0600); + chmod("{$path}/oauth-public.key", 0600); + + return static::$key_path = $path; + } +} diff --git a/tests/Feature/Rules/IsValidMobileNumberRuleTest.php b/tests/Feature/Rules/IsValidMobileNumberRuleTest.php deleted file mode 100644 index 91951e9..0000000 --- a/tests/Feature/Rules/IsValidMobileNumberRuleTest.php +++ /dev/null @@ -1,103 +0,0 @@ -checkRule($rule, 'number', '7771234'); - $this->assertTrue($value); - - $rule = new IsValidMobileNumber('user'); - $value = $this->checkRule($rule, 'number', '9771234'); - $this->assertTrue($value); - - $rule = new IsValidMobileNumber('user'); - $value = $this->checkRule($rule, 'number', '97712345555'); - $this->assertFalse($value); - - $rule = (new IsValidMobileNumber('user'))->setCountryCode('966'); - $value = $this->checkRule($rule, 'number', '977123455555'); - $this->assertTrue($value); - } - - #[Test] - public function it_can_validate_that_no_other_user_of_same_type_has_the_number() - { - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'country_code' => MobileVerification::defaultCountryCode(), - 'number' => '7825222', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $rule = (new IsValidMobileNumber('user'))->notRegistered(); - $value = $this->checkRule($rule, 'number', '7825222'); - $this->assertFalse($value); - } - - #[Test] - public function it_can_check_if_the_number_is_already_a_registered_number() - { - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'country_code' => MobileVerification::defaultCountryCode(), - 'number' => '7825222', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $rule = (new IsValidMobileNumber('user'))->registered(); - $value = $this->checkRule($rule, 'number', '7825222'); - $this->assertTrue($value); - } - - #[Test] - // it can validate if otp can be sent to the number - public function it_can_validate_if_otp_can_be_sent_to_the_number() - { - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'country_code' => MobileVerification::defaultCountryCode(), - 'number' => '7825222', - 'user_type' => 'user', - 'user_id' => $user->id, - 'attempts' => 0, - ]); - - $this->travel(config('mobile-verification.attempt_expiry') + 1)->minutes(); - - $rule = (new IsValidMobileNumber('user'))->registered()->canSendOtp(); - $value = $this->checkRule($rule, 'number', '7825222'); - $this->assertTrue($value); - - $user_two = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'country_code' => MobileVerification::defaultCountryCode(), - 'number' => '7326655', - 'user_type' => 'user', - 'user_id' => $user_two->id, - 'attempts' => config('mobile-verification.max_attempts'), - ]); - - $rule = (new IsValidMobileNumber('user'))->registered()->canSendOtp(); - $value = $this->checkRule($rule, 'number', '7326655'); - $this->assertFalse($value); - } -} diff --git a/tests/Feature/SanctumApiTest.php b/tests/Feature/SanctumApiTest.php new file mode 100644 index 0000000..40c0df4 --- /dev/null +++ b/tests/Feature/SanctumApiTest.php @@ -0,0 +1,168 @@ +set('javaabu-auth.api', 'sanctum'); + $app['config']->set('auth.providers.users.model', User::class); + $app['config']->set('auth.guards.sanctum', ['driver' => 'sanctum', 'provider' => null]); + } + + protected function defineRoutes($router): void + { + $this->registerRoutes(); + + Route::mobileApiRoutes('user'); + Route::middleware(['api', 'auth:sanctum'])->get('api/me', fn () => request()->user()->name); + } + + #[Test] + public function apps_get_a_token_for_a_number_and_code(): void + { + User::factory()->withMobileNumber('7771234')->create(['name' => 'Ahmed']); + + $code_id = $this->postJson('/api/mobile/code', ['number' => '7771234']) + ->assertOk() + ->assertJsonPath('formatted_number', '+9607771234') + ->json('verification_code_id'); + + $token = $this->postJson('/api/mobile/token', [ + 'number' => '7771234', + 'verification_code' => $this->sentCode(), + 'verification_code_id' => $code_id, + 'device_name' => 'iPhone', + ]) + ->assertCreated() + ->assertJsonPath('token_type', 'Bearer') + ->json('token'); + + $this->app['auth']->forgetGuards(); + + $this->withToken($token)->getJson('/api/me')->assertOk()->assertSee('Ahmed'); + } + + #[Test] + public function a_wrong_code_gets_no_token(): void + { + User::factory()->withMobileNumber('7771234')->create(); + + $code_id = $this->postJson('/api/mobile/code', ['number' => '7771234'])->json('verification_code_id'); + + $this->postJson('/api/mobile/token', [ + 'number' => '7771234', + 'verification_code' => 'nope', + 'verification_code_id' => $code_id, + 'device_name' => 'iPhone', + ])->assertUnprocessable()->assertJsonValidationErrors('verification_code'); + + $this->postJson('/api/mobile/token', [ + 'number' => '7770000', + 'verification_code' => '123456', + 'verification_code_id' => $code_id, + 'device_name' => 'iPhone', + ])->assertUnprocessable()->assertJsonValidationErrors('number'); + } + + #[Test] + public function two_factor_users_also_send_their_authenticator_code(): void + { + User::factory()->withTwoFactor()->withMobileNumber('7771234')->create(); + + $code_id = $this->postJson('/api/mobile/code', ['number' => '7771234'])->json('verification_code_id'); + + $this->postJson('/api/mobile/token', [ + 'number' => '7771234', + 'verification_code' => $this->sentCode(), + 'verification_code_id' => $code_id, + 'device_name' => 'iPhone', + ])->assertUnprocessable()->assertJsonValidationErrors('code'); + + $this->travel(31)->seconds(); + $code_id = $this->postJson('/api/mobile/code', ['number' => '7771234'])->json('verification_code_id'); + + $this->postJson('/api/mobile/token', [ + 'number' => '7771234', + 'verification_code' => $this->lastSentCode(), + 'verification_code_id' => $code_id, + 'device_name' => 'iPhone', + 'code' => '000000', + ])->assertUnprocessable()->assertJsonValidationErrors('code'); + + $this->travel(31)->seconds(); + $code_id = $this->postJson('/api/mobile/code', ['number' => '7771234'])->json('verification_code_id'); + + $this->postJson('/api/mobile/token', [ + 'number' => '7771234', + 'verification_code' => $this->lastSentCode(), + 'verification_code_id' => $code_id, + 'device_name' => 'iPhone', + 'recovery_code' => 'nope', + ])->assertUnprocessable()->assertJsonValidationErrors('recovery_code'); + + // The SMS code was used up, so ask again with a recovery code + $this->travel(31)->seconds(); + + $code_id = $this->postJson('/api/mobile/code', ['number' => '7771234'])->json('verification_code_id'); + + $this->postJson('/api/mobile/token', [ + 'number' => '7771234', + 'verification_code' => $this->lastSentCode(), + 'verification_code_id' => $code_id, + 'device_name' => 'iPhone', + 'recovery_code' => 'recovery-code-1', + ])->assertCreated(); + } + + #[Test] + public function banned_users_get_no_token(): void + { + User::factory()->banned()->withMobileNumber('7771234')->create(); + + $code_id = $this->postJson('/api/mobile/code', ['number' => '7771234'])->json('verification_code_id'); + + $this->postJson('/api/mobile/token', [ + 'number' => '7771234', + 'verification_code' => $this->sentCode(), + 'verification_code_id' => $code_id, + 'device_name' => 'iPhone', + ])->assertUnprocessable()->assertJsonValidationErrors('number'); + } + + protected function lastSentCode(): string + { + $codes = []; + + Notification::assertSentTo( + MobileNumber::query()->sole(), + VerificationCodeNotification::class, + function ($notification) use (&$codes) { + $codes[] = $notification->verification_code; + + return true; + }, + ); + + return end($codes); + } +} diff --git a/tests/Feature/Traits/InteractsWithMobileNumbersTest.php b/tests/Feature/Traits/InteractsWithMobileNumbersTest.php deleted file mode 100644 index 354a605..0000000 --- a/tests/Feature/Traits/InteractsWithMobileNumbersTest.php +++ /dev/null @@ -1,83 +0,0 @@ -assertDatabaseCount('activity_log', 0); - - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7528222', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $new_mobile_number = MobileNumber::factory()->create([ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => null, - ]); - - $this->actingAs($user); - - $this->patch('mobile-verification/update', [ - 'number' => '7326655', - 'verification_code' => $new_mobile_number->generateVerificationCode(), - 'verification_code_id' => $new_mobile_number->verification_code_id->toString(), - ]) - ->assertSessionHasNoErrors() - ->assertRedirect(); - - $this->assertDatabaseHas('mobile_numbers', [ - 'number' => '7326655', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $activity = Activity::where('description', 'updated') - ->where('subject_type', 'mobile_number') - ->where('subject_id', $new_mobile_number->id) - ->where('causer_type', 'user') - ->where('causer_id', $user->id) - ->where('properties->attributes->user_id', $user->id) - ->first(); - - $this->assertNotNull($activity); - } - - #[Test] - public function it_deletes_the_associated_mobile_number_when_the_user_is_deleted(): void - { - $user = User::factory()->create(); - $mobile_number = MobileNumber::factory()->create([ - 'number' => '7528222', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $this->assertDatabaseCount('mobile_numbers', 1); - - $user->delete(); - $this->assertDatabaseCount('mobile_numbers', 1); - - $user->forceDelete(); - $this->assertDatabaseCount('mobile_numbers', 0); - } -} diff --git a/tests/Feature/VerificationCodeTest.php b/tests/Feature/VerificationCodeTest.php deleted file mode 100644 index f396261..0000000 --- a/tests/Feature/VerificationCodeTest.php +++ /dev/null @@ -1,146 +0,0 @@ -withoutExceptionHandling(); - $user = $this->getUserWithMobileNumber(); - - - $this->assertDatabaseHas('mobile_numbers', [ - 'number' => '7528222', - 'country_code' => '960', - 'user_id' => $user->id, - 'user_type' => 'user', - 'verification_code' => null, - ]); - - $this->postJson('/api/mobile-verifications/verification-code', [ - 'user_type' => 'user', - 'number' => '7528222', - ]); - - $mobile_number = $user->phone()->first(); - $this->assertNotNull($mobile_number->verification_code); - } - - public function test_too_many_invalid_attempts_will_lock_the_mobile_number() - { - $user = User::factory()->create(); - MobileNumber::unguard(); - - $mobile_number = MobileNumber::create([ - 'number' => '7528222', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $this->mock(VerificationCodeGenerator::class, function ($mock) use ($mobile_number) { - $mock->shouldReceive('handle') - ->andReturn('123456'); - }); - - $response = $this->postJson('/api/mobile-verifications/verification-code', [ - 'user_type' => 'user', - 'number' => '7528222', - ]); - - $verification_code_id = $response->json()['verification_code_id']; - $max_attempts = config('mobile-verification.max_attempts'); - $try = 1; - - foreach (range(1, $max_attempts + 20) as $attempt) { - $this->postJson('/api/mobile-verifications/verify', [ - 'user_type' => 'user', - 'number' => '7528222', - 'verification_code' => '999999', - 'verification_code_id' => $verification_code_id, - ]); - - $mobile_number->refresh(); - $this->assertEquals($mobile_number->attempts, $try); - - if ($max_attempts == $try) { - $mobile_number->refresh(); - $this->assertEquals($mobile_number->is_locked, true); - } - - $try++; - } - - $this->postJson('/api/mobile-verifications/verify', [ - 'user_type' => 'user', - 'number' => '7528222', - 'verification_code' => '999999', - 'verification_code_id' => $verification_code_id, - ]) - ->assertJsonValidationErrors(['verification_code']); - - $this->postJson('/api/mobile-verifications/verification-code', [ - 'user_type' => 'user', - 'number' => '7528222', - ]) - ->assertJsonValidationErrors(['number']); - } - - public function test_verification_code_request_to_soft_deleted_user_returns_correct_validation_error() - { - $user = User::factory()->create(); - MobileNumber::unguard(); - - $mobile_number = MobileNumber::create([ - 'number' => '7528222', - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - $user->delete(); - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'deleted_at' => now(), - ]); - - $this->mock(VerificationCodeGenerator::class, function ($mock) use ($mobile_number) { - $mock->shouldReceive('handle') - ->andReturn('123456'); - }); - - $response = $this->postJson('/api/mobile-verifications/verification-code', [ - 'user_type' => 'user', - 'number' => '7528222', - ]); - - $response->assertJsonValidationErrors(['number']); - - $mobile_number->refresh(); - $this->assertNull($mobile_number->verification_code); - } - - public function getUserWithMobileNumber(string $number = '7528222'): User - { - $user = User::factory()->create(); - MobileNumber::unguard(); - - $mobile_number = MobileNumber::create([ - 'number' => $number, - 'country_code' => '960', - 'user_type' => 'user', - 'user_id' => $user->id, - ]); - - return $user; - } -} diff --git a/tests/Fixtures/CreateUserWithIdCard.php b/tests/Fixtures/CreateUserWithIdCard.php new file mode 100644 index 0000000..d59d184 --- /dev/null +++ b/tests/Fixtures/CreateUserWithIdCard.php @@ -0,0 +1,20 @@ +newModel(); + + $user->forceFill(['name' => "{$input['name']} ({$input['id_card']})"])->save(); + + return $user; + } +} diff --git a/tests/Fixtures/CustomMobileLoginController.php b/tests/Fixtures/CustomMobileLoginController.php new file mode 100644 index 0000000..346f55d --- /dev/null +++ b/tests/Fixtures/CustomMobileLoginController.php @@ -0,0 +1,16 @@ + $this->userType($request)->name()]); + } +} diff --git a/tests/Fixtures/DenyAllMobileNumberPolicy.php b/tests/Fixtures/DenyAllMobileNumberPolicy.php new file mode 100644 index 0000000..5522fc2 --- /dev/null +++ b/tests/Fixtures/DenyAllMobileNumberPolicy.php @@ -0,0 +1,15 @@ +label(__('Name'))->required()->maxLength(255), + TextInput::make('id_card')->label(__('ID card'))->required()->rules(['regex:/^A\d{6}$/'])->dehydrated(), + ]; + } +} diff --git a/tests/Http/BladePagesTest.php b/tests/Http/BladePagesTest.php new file mode 100644 index 0000000..d8bf371 --- /dev/null +++ b/tests/Http/BladePagesTest.php @@ -0,0 +1,114 @@ +app->setLocale('dv'); + + $this->get('/login/mobile') + ->assertOk() + ->assertSee('', false) + ->assertSee('މޯބައިލް ނަންބަރާއެކު ލޮގިން ކުރޭ') + ->assertSee('ކޯޑު ފޮނުވާ'); + + $this->get('/register/mobile')->assertOk()->assertSee('މޯބައިލް ނަންބަރާއެކު ސައިން އަޕް ކުރޭ'); + } + + #[Test] + public function numbers_and_codes_stay_left_to_right(): void + { + $this->app->setLocale('dv'); + + User::factory()->withMobileNumber('7771234')->create(); + + $this->get('/login/mobile')->assertSee('name="number"', false)->assertSee('dir="ltr"', false); + + $this->post('/login/mobile', ['number' => '7771234']); + + $this->get('/login/mobile/verify') + ->assertOk() + ->assertSee('+9607771234', false) + ->assertSee('ލޮގިން ވުމަށް ކޯޑު ޖައްސަވާ'); + } + + #[Test] + public function the_messages_are_in_dhivehi(): void + { + $this->app->setLocale('dv'); + + $this->from('/login/mobile') + ->post('/login/mobile', ['number' => '7770000']) + ->assertSessionHasErrors(['number' => 'މި މޯބައިލް ނަންބަރާ ގުޅޭ އެކައުންޓެއް ނެތެވެ.']); + + $this->post('/login/mobile', ['number' => '1234']) + ->assertSessionHasErrors(['number' => 'މޯބައިލް ނަންބަރު އަކީ ރަނގަޅު މޯބައިލް ނަންބަރެއް ނޫނެވެ.']); + } + + #[Test] + public function the_sms_is_sent_in_the_language_of_the_page(): void + { + $this->app->setLocale('dv'); + + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + + $code = $this->sentCode(); + $notification = new VerificationCodeNotification($code); + + $this->assertSame('dv', $notification->locale); + $this->assertStringContainsString("Javaabu ގެ ވެރިފިކޭޝަން ކޯޑަކީ {$code}.", $notification->toSms(null)); + + $this->app->setLocale('en'); + + $this->assertSame("Your Javaabu verification code is {$code}. It expires in 10 minutes. Do not share it with anyone.", (new VerificationCodeNotification($code))->toSms(null)); + } + + #[Test] + public function the_details_form_types_thaana_on_dhivehi_pages(): void + { + $this->app->setLocale('dv'); + + $this->post('/register/mobile', ['number' => '7771234']); + $this->post('/register/mobile/verify', ['verification_code' => $this->sentCode()]); + + $this->get('/register/mobile/details') + ->assertOk() + ->assertSee('ތިޔަ ފަރާތުގެ މަޢުލޫމާތު') + ->assertSee('thaana', false); + + $this->post('/register/mobile/details', ['name' => 'އަޙްމަދު ޢަލީ'])->assertRedirect('/dashboard'); + + $this->assertSame('އަޙްމަދު ޢަލީ', User::query()->sole()->name); + } + + #[Test] + public function the_views_use_logical_css_only(): void + { + $files = [ + ...glob(__DIR__ . '/../../resources/views/*.blade.php'), + ...glob(__DIR__ . '/../../resources/views/*/*.blade.php'), + ...glob(__DIR__ . '/../../resources/js/*/*.tsx'), + ...glob(__DIR__ . '/../../resources/js/*/*/*.tsx'), + ]; + + $this->assertNotEmpty($files); + + foreach ($files as $file) { + $this->assertDoesNotMatchRegularExpression( + '/\b(?:ml|mr|pl|pr|left|right|text-left|text-right|border-l|border-r|rounded-l|rounded-r)-/', + file_get_contents($file), + basename($file) . ' uses a physical direction utility; use ms-/me-/ps-/pe-/start-/end-', + ); + } + } +} diff --git a/tests/Http/InertiaPagesTest.php b/tests/Http/InertiaPagesTest.php new file mode 100644 index 0000000..bcdd486 --- /dev/null +++ b/tests/Http/InertiaPagesTest.php @@ -0,0 +1,149 @@ +set('javaabu-auth.views', 'inertia'); + $app['config']->set('inertia.testing.ensure_pages_exist', false); + $app['config']->set('inertia.root_view', 'app'); + } + + /** @return array */ + public static function guestPages(): array + { + return [ + 'login' => ['/login/mobile', 'mobile-verification::Login'], + 'register' => ['/register/mobile', 'mobile-verification::Register'], + 'admin login' => ['/admin/login/mobile', 'mobile-verification::Login'], + ]; + } + + #[Test] + #[DataProvider('guestPages')] + public function it_renders_the_guest_pages_as_inertia_pages(string $url, string $component): void + { + $this->get($url) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component($component, false) + ->has('auth_type.name') + ->where('mobile.country_codes', ['960' => '+960']) + ->where('mobile.code_length', 6) + ->has('strings.send_code') + ->has('strings.log_out')); + } + + #[Test] + public function every_page_file_exists(): void + { + foreach (['Login', 'Register', 'Verify', 'RegisterDetails', 'Settings/MobileNumber'] as $page) { + $this->assertFileExists(__DIR__ . "/../../resources/js/Pages/{$page}.tsx"); + } + } + + #[Test] + public function the_login_page_props_match_the_type(): void + { + $this->get('/admin/login/mobile') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('auth_type.name', 'admin') + ->where('urls', fn ($urls) => $urls['login.mobile.store'] === url('/admin/login/mobile') && $urls['login'] === url('/admin/login')) + ->where('can_register', false) + ->etc()); + + $this->get('/login/mobile') + ->assertInertia(fn (AssertableInertia $page) => $page->where('can_register', true)->etc()); + } + + #[Test] + public function the_code_page_gets_the_pending_number(): void + { + User::factory()->withMobileNumber('7771234')->create(); + + $this->post('/login/mobile', ['number' => '7771234']); + + $this->get('/login/mobile/verify') + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('mobile-verification::Verify', false) + ->where('purpose', 'login') + ->where('mobile_number.formatted_number', '+9607771234') + ->where('mobile_number.expires_in', 600) + ->where('mobile_number.resend_in', 30) + ->where('verify_url', url('/login/mobile/verify')) + ->where('resend_url', url('/login/mobile/resend')) + ->etc()); + } + + #[Test] + public function the_details_page_gets_the_stencil_schema(): void + { + $this->post('/register/mobile', ['number' => '7771234']); + $this->post('/register/mobile/verify', ['verification_code' => $this->sentCode()]); + + $this->get('/register/mobile/details') + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('mobile-verification::RegisterDetails', false) + ->where('form.stencil', 1) + ->where('form.action', url('/register/mobile/details')) + ->where('form.components.0.name', 'name') + ->where('form.components.1.name', 'email') + ->where('mobile_number.formatted_number', '+9607771234') + ->etc()); + } + + #[Test] + public function the_settings_page_gets_the_current_number(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->actingAs($user, 'web') + ->get('/settings/mobile-number') + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('mobile-verification::Settings/MobileNumber', false) + ->where('current_number.formatted_number', '+9607771234') + ->where('account_links', fn ($links) => collect($links)->contains(fn ($link) => $link['key'] === 'mobile-number' + && $link['url'] === url('/settings/mobile-number') + && $link['active'] === true)) + ->where('urls', fn ($urls) => $urls['mobile-number.store'] === url('/settings/mobile-number')) + ->etc()); + } + + #[Test] + public function the_stack_can_be_chosen_for_the_package_alone(): void + { + config()->set('mobile-verification.views', 'blade'); + + $this->get('/login/mobile')->assertOk()->assertSee('data-mobile-page="login"', false); + } + + #[Test] + public function the_strings_are_translated(): void + { + $this->app->setLocale('dv'); + + $this->get('/login/mobile') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('strings.send_code', 'ކޯޑު ފޮނުވާ') + ->where('strings.log_in_with_mobile', 'މޯބައިލް ނަންބަރާއެކު ލޮގިން ކުރޭ') + ->etc()); + } +} diff --git a/tests/Models/Admin.php b/tests/Models/Admin.php new file mode 100644 index 0000000..f1b7d12 --- /dev/null +++ b/tests/Models/Admin.php @@ -0,0 +1,24 @@ +app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); - - $this->app['config']->set('session.serialization', 'php'); - + Manifest::fake(); Notification::fake(); - View::addLocation(__DIR__ . '/TestSupport/views'); + // Codes are saved to the second; keep expiry counts exact + $this->freezeSecond(); } - protected function getPackageProviders($app) + protected function getPackageProviders($app): array { return [ - MobileVerificationServiceProvider::class, - TestServiceProvider::class, + PermissionServiceProvider::class, + PermissionsServiceProvider::class, + ManifestServiceProvider::class, + FormsServiceProvider::class, + StencilServiceProvider::class, + PasskeysServiceProvider::class, + AuthServiceProvider::class, SmsNotificationsServiceProvider::class, - HelpersServiceProvider::class, - ActivitylogServiceProvider::class, - PassportServiceProvider::class, + MobileVerificationServiceProvider::class, ]; } - protected function withRecaptchaPassing(): void + protected function defineEnvironment($app): void { - ReCaptcha::shouldReceive('validate') - ->once() - ->andReturnTrue(); + $config = $app['config']; + + $config->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + $config->set('app.url', 'http://localhost'); + $config->set('app.name', 'Javaabu'); + $config->set('passkeys.user_handle_secret', 'test-secret'); + $config->set('session.serialization', 'php'); + $config->set('view.paths', [__DIR__ . '/views', ...$config->get('view.paths', [])]); + + $config->set('auth.guards', [ + 'web' => ['driver' => 'session', 'provider' => 'users'], + 'web_admin' => ['driver' => 'session', 'provider' => 'admins'], + ]); + + $config->set('auth.providers', [ + 'users' => ['driver' => 'eloquent', 'model' => User::class], + 'admins' => ['driver' => 'eloquent', 'model' => Admin::class], + ]); + + $config->set('auth.passwords', [ + 'users' => ['provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 0], + 'admins' => ['provider' => 'admins', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 0], + ]); + + $config->set('javaabu-auth.user_types', [ + 'user' => [ + 'guard' => 'web', + 'passwords' => 'users', + 'route_name' => '', + 'home' => '/dashboard', + 'features' => ['email_verification' => false], + 'login_methods' => [MobileLoginMethod::class], + ], + 'admin' => [ + 'guard' => 'web_admin', + 'passwords' => 'admins', + 'home' => '/admin', + 'features' => ['registration' => false, 'email_verification' => false], + 'login_methods' => [MobileLoginMethod::class], + ], + ]); + + $config->set('javaabu-auth.layouts', [ + 'auth' => 'layouts.app', + 'account' => 'layouts.app', + ]); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/database'); } - protected function withRecaptchaFailing(): void + protected function defineRoutes($router): void { - ReCaptcha::shouldReceive('validate') - ->once() - ->andReturnFalse(); + $this->registerRoutes(); } - public function checkRule(mixed $rule, string $attribute, mixed $value): bool + protected function registerRoutes(): void { - $passed = true; - $fail = function () use (&$passed) { - $passed = false; - }; + Route::authRoutes('user'); + Route::authRoutes('admin', ['prefix' => 'admin']); + + Route::mobileNumberRoutes('user'); + Route::mobileNumberRoutes('admin', ['prefix' => 'admin/settings']); + + Route::middleware(['web', 'auth.type:user', 'mobile.verified:user'])->get('dashboard', fn () => 'user dashboard')->name('dashboard'); + Route::middleware(['web', 'auth.type:admin', 'mobile.verified:admin'])->get('admin', fn () => 'admin dashboard')->name('admin.home'); + } + + protected function userType(string $name = 'user'): UserType + { + return UserTypes::get($name); + } + + /** + * The code in the last SMS sent to a number + */ + protected function sentCode(string $formatted_number = '+9607771234'): ?string + { + $code = null; + + Notification::assertSentTo( + MobileVerification::mobileNumberModel()::query()->get()->first(fn ($number) => $number->formatted_number === $formatted_number), + VerificationCodeNotification::class, + function (VerificationCodeNotification $notification) use (&$code) { + $code = $notification->verification_code; - $rule->validate($attribute, $value, $fail); + return true; + }, + ); - return $passed; + return $code; } } diff --git a/tests/TestSupport/Controllers/LoginController.php b/tests/TestSupport/Controllers/LoginController.php deleted file mode 100644 index 8e7ae4a..0000000 --- a/tests/TestSupport/Controllers/LoginController.php +++ /dev/null @@ -1,44 +0,0 @@ -validate([ - 'user_type' => 'required|string|in:user' - ]); - - return match ($request->user_type) { - 'user' => User::class, - }; - } - - public function mustBeARegisteredMobileNumber(array $request_data): ?bool - { - if (data_get($request_data, 'user_type') === 'user') { - return true; - } - - return null; - } - - public function getVerificationCodeSmsNotification(string $verification_code, MobileNumber $mobile_number): SmsNotification - { - $notification_class = config('mobile-verification.notifications.login'); - return new $notification_class($verification_code, $mobile_number?->user?->name); - } - - -} diff --git a/tests/TestSupport/Controllers/RegisterController.php b/tests/TestSupport/Controllers/RegisterController.php deleted file mode 100644 index 300edb6..0000000 --- a/tests/TestSupport/Controllers/RegisterController.php +++ /dev/null @@ -1,78 +0,0 @@ -name = $data['name']; - $user->email = $data['email']; - $user->save(); - - return $user; - } - - public function getRegisterFieldsValidationRules(Request $request): array - { - return [ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], - ]; - } - - public function getVerificationCodeRequestFormView(): ?string - { - return "verification-code-request-form"; - } - - public function getVerificationCodeFormView(): ?string - { - return "verification-code-entry-form"; - } - - public function verificationCodeSuccessRedirectUrl(): string - { - // TODO: Implement verificationCodeSuccessRedirectUrl() method. - } - - public function getRegistrationFormView(): string - { - // TODO: Implement getRegistrationFormView() method. - } - - public function enableReCaptcha(): bool - { - return false; - } - - public function guardName(): string - { - return 'web'; - } - - public function userType(): \Javaabu\Auth\User - { - return new User(); - } -} diff --git a/tests/TestSupport/Factories/UserFactory.php b/tests/TestSupport/Factories/UserFactory.php deleted file mode 100644 index f99f3e4..0000000 --- a/tests/TestSupport/Factories/UserFactory.php +++ /dev/null @@ -1,19 +0,0 @@ - fake()->name, - 'email' => fake()->unique()->safeEmail, - ]; - } -} diff --git a/tests/TestSupport/Models/User.php b/tests/TestSupport/Models/User.php deleted file mode 100644 index b848d06..0000000 --- a/tests/TestSupport/Models/User.php +++ /dev/null @@ -1,94 +0,0 @@ -getNumber(); - $user = User::whereHas('phone', function ($query) use ($number) { - $query->where('number', $number); - })->first(); - - return $user; - } - - public function redirectToMobileVerificationUrl(): RedirectResponse - { - return to_route('mobile-verifications.login.create'); - } - - public function getAdminUrlAttribute(): string - { - // TODO: Implement getAdminUrlAttribute() method. - } - - public function passwordUpdateUrl() - { - // TODO: Implement passwordUpdateUrl() method. - } - - public function guardName(): string - { - return 'web'; - } - - public function homeUrl() - { - // TODO: Implement homeUrl() method. - } - - public function loginUrl() - { - // TODO: Implement loginUrl() method. - } - - public function getRouteForPasswordReset() - { - // TODO: Implement getRouteForPasswordReset() method. - } - - public function getRouteForEmailVerification() - { - // TODO: Implement getRouteForEmailVerification() method. - } - - public function inactiveNoticeUrl() - { - // TODO: Implement inactiveNoticeUrl() method. - } -} diff --git a/tests/TestSupport/Providers/TestServiceProvider.php b/tests/TestSupport/Providers/TestServiceProvider.php deleted file mode 100644 index a012489..0000000 --- a/tests/TestSupport/Providers/TestServiceProvider.php +++ /dev/null @@ -1,44 +0,0 @@ -loadMigrationsFrom([ - __DIR__ . '/../database', - __DIR__ . '/../../../database/migrations', - __DIR__ . '/../../../vendor/laravel/passport/database/migrations' - ]); - - $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); - - // morph map - Relation::morphMap([ - 'user' => User::class, - 'mobile_number' => MobileNumber::class, - ]); - - Passport::loadKeysFrom(__DIR__ . '/../../passport-keys'); - - Passport::$validateKeyPermissions = false; - } - - /** - * Register the application services. - */ - public function register() - { - - } -} diff --git a/tests/TestSupport/Stubs/ClientEntity.php b/tests/TestSupport/Stubs/ClientEntity.php deleted file mode 100644 index 5a7d907..0000000 --- a/tests/TestSupport/Stubs/ClientEntity.php +++ /dev/null @@ -1,23 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->string('status')->index(); - $table->timestamps(); - $table->softDeletes(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('users'); - } -}; diff --git a/tests/TestSupport/routes/web.php b/tests/TestSupport/routes/web.php deleted file mode 100644 index b7b52c1..0000000 --- a/tests/TestSupport/routes/web.php +++ /dev/null @@ -1,55 +0,0 @@ -name('login'); - -Route::group([ - 'middleware' => 'web', - 'prefix' => '/mobile-verification', -], function () { - Route::get('/login', [LoginController::class, 'showVerificationCodeRequestForm'])->name('mobile-verifications.login.create'); - Route::post('/login', [LoginController::class, 'requestVerificationCode'])->name('mobile-verifications.login.store'); - Route::match(['PATCH', 'PUT'], '/login', [LoginController::class, 'verifyVerificationCode'])->name('mobile-verifications.login.update'); - - - Route::get('/register', [RegisterController::class, 'showVerificationCodeRequestForm'])->name('mobile-verifications.register.create'); - Route::post('/register', [RegisterController::class, 'requestVerificationCode'])->name('mobile-verifications.register.store'); - Route::match(['PATCH', 'PUT'], '/register', [RegisterController::class, 'register'])->name('mobile-verifications.register.update'); - - Route::get('/update', [MobileNumberUpdateController::class, 'showVerificationCodeRequestForm'])->name('mobile-verifications.update.create'); - Route::post('/update', [MobileNumberUpdateController::class, 'requestVerificationCode'])->name('mobile-verifications.update.store'); - Route::match(['PATCH', 'PUT'], '/update', [MobileNumberUpdateController::class, 'verifyVerificationCode'])->name('mobile-verifications.update.update'); - - - Route::get('/updated', function () { - return "Mobile number updated"; - })->name('mobile-verifications.updated'); - - Route::get('/protected', function () { - return "Protected route"; - })->middleware(['auth:web', 'mobile-verified:web']); -}); - - -Route::group([ - 'prefix' => 'api' -], function () { - - Route::get('/protected', function () { - return "Protected route"; - })->middleware(['auth:api', 'mobile-verified']); - - Route::post('mobile-verifications/verification-code', [MobileVerificationsController::class, 'requestVerificationCode'])->name('mobile-verifications.verification-code'); - Route::post('mobile-verifications/verify', [MobileVerificationsController::class, 'verifyVerificationCode'])->name('mobile-verifications.verification-code'); -}); diff --git a/tests/TestSupport/views/verification-code-entry-form.blade.php b/tests/TestSupport/views/verification-code-entry-form.blade.php deleted file mode 100644 index 5c8d81d..0000000 --- a/tests/TestSupport/views/verification-code-entry-form.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -

Enter Verification Code

- - - diff --git a/tests/TestSupport/views/verification-code-request-form.blade.php b/tests/TestSupport/views/verification-code-request-form.blade.php deleted file mode 100644 index f368e45..0000000 --- a/tests/TestSupport/views/verification-code-request-form.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -

Verification Code Request Form

-
- -
diff --git a/tests/Unit/MobileNumberTest.php b/tests/Unit/MobileNumberTest.php new file mode 100644 index 0000000..ef6d668 --- /dev/null +++ b/tests/Unit/MobileNumberTest.php @@ -0,0 +1,161 @@ +create(['number' => '077 712-34', 'country_code' => '+960']); + + $this->assertSame('7771234', $mobile_number->number); + $this->assertSame('960', $mobile_number->country_code); + $this->assertSame('+9607771234', $mobile_number->formatted_number); + $this->assertSame('7771234', $mobile_number->short_formatted_number); + $this->assertSame('+9607771234', $mobile_number->routeNotificationForSms()); + } + + #[Test] + public function other_country_codes_keep_the_prefix_in_the_short_number(): void + { + config()->set('mobile-verification.allowed_country_codes', ['960', '94']); + + $mobile_number = MobileNumber::factory()->create(['number' => '771234567', 'country_code' => '94']); + + $this->assertSame('+94771234567', $mobile_number->short_formatted_number); + $this->assertSame(['960' => '+960', '94' => '+94'], MobileVerification::countryCodeOptions()); + } + + #[Test] + public function it_hashes_codes_and_checks_them(): void + { + $mobile_number = MobileNumber::factory()->create(); + + $code = $mobile_number->generateVerificationCode(); + + $this->assertMatchesRegularExpression('/^\d{6}$/', $code); + $this->assertNotSame($code, $mobile_number->getAttributes()['verification_code']); + $this->assertNotNull($mobile_number->verification_code_id); + $this->assertArrayNotHasKey('verification_code', $mobile_number->toArray()); + + $this->assertFalse($mobile_number->checkVerificationCode($code === '000000' ? '111111' : '000000')); + $this->assertSame(1, $mobile_number->fresh()->attempts); + + $this->assertTrue($mobile_number->checkVerificationCode($code)); + } + + #[Test] + public function codes_expire(): void + { + $mobile_number = MobileNumber::factory()->create(); + $mobile_number->generateVerificationCode(); + + $this->assertFalse($mobile_number->isVerificationCodeExpired()); + $this->assertSame(600, $mobile_number->verificationCodeExpiresIn()); + + $this->travel(10)->minutes(); + + $this->assertTrue($mobile_number->isVerificationCodeExpired()); + $this->assertSame(0, $mobile_number->verificationCodeExpiresIn()); + } + + #[Test] + public function a_new_code_can_be_sent_after_the_resend_interval(): void + { + $mobile_number = MobileNumber::factory()->create(); + + $this->assertTrue($mobile_number->canRequestCode()); + + $mobile_number->generateVerificationCode(); + + $this->assertTrue($mobile_number->wasSentRecently()); + $this->assertSame(30, $mobile_number->resendVerificationCodeIn()); + + $this->travel(31)->seconds(); + + $this->assertFalse($mobile_number->wasSentRecently()); + $this->assertTrue($mobile_number->canRequestCode()); + } + + #[Test] + public function too_many_wrong_codes_lock_the_number_for_a_while(): void + { + $mobile_number = MobileNumber::factory()->create(); + $mobile_number->generateVerificationCode(); + + foreach (range(1, 5) as $attempt) { + $mobile_number->checkVerificationCode('wrong'); + } + + $this->assertTrue($mobile_number->isLocked()); + $this->assertSame(0, $mobile_number->remainingAttempts()); + $this->assertSame(30 * 60, $mobile_number->lockExpiresIn()); + $this->assertFalse($mobile_number->canRequestCode()); + + $this->travel(30)->minutes(); + + $this->assertFalse($mobile_number->isLocked()); + + $mobile_number->resetAttempts(); + + $this->assertSame(0, $mobile_number->attempts); + $this->assertFalse($mobile_number->hasVerificationCode()); + } + + #[Test] + public function the_code_length_is_configurable(): void + { + config()->set('mobile-verification.code_length', 4); + + $this->assertMatchesRegularExpression('/^\d{4}$/', MobileNumber::factory()->create()->generateVerificationCode()); + } + + #[Test] + public function it_finds_numbers_as_users_write_them(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + + $this->assertTrue(MobileNumber::query()->hasNumber('7771234')->exists()); + $this->assertTrue(MobileNumber::query()->hasNumber('+9607771234')->exists()); + $this->assertTrue(MobileNumber::query()->search('777')->exists()); + $this->assertTrue(MobileNumber::query()->search('+960777')->exists()); + $this->assertFalse(MobileNumber::query()->search('999')->exists()); + + $this->assertTrue(MobileVerification::find('user', null, '7771234')->user->is($user)); + $this->assertNull(MobileVerification::find('admin', null, '7771234')); + } + + #[Test] + public function the_same_number_can_belong_to_a_user_and_an_admin(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + $admin = Admin::factory()->withMobileNumber('7771234')->create(); + + $this->assertSame('+9607771234', $user->formatted_mobile_number); + $this->assertSame('+9607771234', $admin->formatted_mobile_number); + $this->assertSame(2, MobileNumber::query()->count()); + } + + #[Test] + public function the_response_data_describes_the_code_without_revealing_it(): void + { + $mobile_number = MobileNumber::factory()->create(); + $code = $mobile_number->generateVerificationCode(); + + $data = $mobile_number->verificationCodeResponseData(); + + $this->assertSame($mobile_number->verification_code_id, $data['verification_code_id']); + $this->assertFalse($data['is_registered']); + $this->assertSame(600, $data['expires_in']); + $this->assertSame(30, $data['resend_in']); + $this->assertNotContains($code, $data); + } +} diff --git a/tests/Unit/MobileVerificationTest.php b/tests/Unit/MobileVerificationTest.php new file mode 100644 index 0000000..b241334 --- /dev/null +++ b/tests/Unit/MobileVerificationTest.php @@ -0,0 +1,87 @@ + User::class, 'guard' => 'web']); + + $this->assertFalse(MobileVerification::enabledFor('vendor')); + + MobileVerification::enableFor('vendor'); + + $this->assertTrue(MobileVerification::enabledFor('vendor')); + $this->assertTrue(MobileVerification::enabledFor(UserTypes::get('user'))); + } + + #[Test] + public function registration_needs_the_type_feature_and_the_config(): void + { + $this->assertTrue(MobileVerification::allowsRegistration(UserTypes::get('user'))); + $this->assertFalse(MobileVerification::allowsRegistration(UserTypes::get('admin'))); + + config()->set('mobile-verification.registration', false); + + $this->assertFalse(MobileVerification::allowsRegistration(UserTypes::get('user'))); + } + + #[Test] + public function it_normalizes_input(): void + { + $this->assertSame('7771234', MobileVerification::normalizeNumber(' 0777-1234 ')); + $this->assertSame('', MobileVerification::normalizeNumber(null)); + $this->assertSame('960', MobileVerification::normalizeCountryCode('+960')); + $this->assertSame('960', MobileVerification::normalizeCountryCode('00960')); + $this->assertSame('960', MobileVerification::normalizeCountryCode('')); + $this->assertSame('', MobileVerification::normalizeCountryCode('', false)); + $this->assertSame('+960', MobileVerification::numberPrefix() . MobileVerification::defaultCountryCode()); + $this->assertSame((new Admin)->getMorphClass(), MobileVerification::morphClassFor('admin')); + } + + #[Test] + public function the_default_number_format(): void + { + $format = new NumberFormatValidator; + + $this->assertTrue($format->passes('960', '7771234')); + $this->assertTrue($format->passes('960', '9991234')); + $this->assertFalse($format->passes('960', '3301234')); + $this->assertFalse($format->passes('960', '77712345')); + $this->assertTrue($format->passes('94', '771234567')); + $this->assertFalse($format->passes('94', '12')); + $this->assertFalse($format->passes('94', '1234567890123456')); + } + + #[Test] + public function the_rule_checks_registration_per_type(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + + $validate = fn ($rule, array $data = ['number' => '7771234']) => Validator::make($data, ['number' => [$rule]])->passes(); + + $this->assertTrue($validate((new IsValidMobileNumber('user'))->registered())); + $this->assertFalse($validate((new IsValidMobileNumber('admin'))->registered())); + $this->assertFalse($validate((new IsValidMobileNumber('user'))->notRegistered())); + $this->assertTrue($validate((new IsValidMobileNumber('user'))->notRegistered()->ignore($user))); + $this->assertTrue($validate((new IsValidMobileNumber($user->getMorphClass(), is_morph_class: true))->registered())); + $this->assertTrue($validate(new IsValidMobileNumber, ['number' => ''])); + $this->assertFalse($validate(new IsValidMobileNumber, ['number' => ['7771234']])); + + config()->set('mobile-verification.allowed_country_codes', ['960', '94']); + + $this->assertTrue(Validator::make(['number' => '771234567', 'country_code' => '+94'], ['number' => [new IsValidMobileNumber]])->passes()); + } +} diff --git a/tests/Unit/NpmDependenciesTest.php b/tests/Unit/NpmDependenciesTest.php new file mode 100644 index 0000000..5353023 --- /dev/null +++ b/tests/Unit/NpmDependenciesTest.php @@ -0,0 +1,83 @@ + $dependencies + * @return array> + */ + protected function check(array $dependencies): array + { + File::put(base_path('package.json'), json_encode(['devDependencies' => (object) $dependencies])); + + Artisan::call('manifest:check', ['--json' => true]); + + return collect(json_decode(Artisan::output(), true))->keyBy('name')->all(); + } + + /** + * @param array> $rows + * @return array + */ + protected function neededByThisPackage(array $rows): array + { + return array_keys(array_filter($rows, fn (array $row) => in_array('mobile-verification', $row['needed_by'] ?? [], true))); + } + + #[Test] + public function blade_apps_are_not_asked_for_the_react_pages_packages(): void + { + $needed = $this->neededByThisPackage($this->check(['vite' => '^8.0'])); + + foreach (self::REACT_PACKAGES as $name) { + $this->assertNotContains($name, $needed); + } + } + + #[Test] + public function inertia_apps_are_asked_for_what_the_pages_import(): void + { + $rows = $this->check(['@inertiajs/react' => '^3.0']); + + foreach (self::REACT_PACKAGES as $name) { + $this->assertContains($name, $this->neededByThisPackage($rows)); + } + } + + #[Test] + public function every_npm_package_is_for_the_pages(): void + { + $package = Manifest::packages()['mobile-verification']; + + $this->assertSame([], $package->npmDependencies(NpmScopes::ASSETS)); + $this->assertSame([], $package->npmPeerDependencies(NpmScopes::ASSETS)); + $this->assertArrayHasKey('@javaabu/forms', $package->npmDependencies(NpmScopes::PAGES)); + } +} diff --git a/tests/Unit/TranslationsTest.php b/tests/Unit/TranslationsTest.php new file mode 100644 index 0000000..a14086d --- /dev/null +++ b/tests/Unit/TranslationsTest.php @@ -0,0 +1,69 @@ +strings() as $string) { + $this->assertArrayHasKey($string, $dv, "Missing Dhivehi for: {$string}"); + $this->assertArrayHasKey($string, $en, "Missing from en.json: {$string}"); + $this->assertMatchesRegularExpression('/\p{Thaana}/u', $dv[$string], "Not Dhivehi: {$string}"); + } + } + + #[Test] + public function the_dhivehi_keeps_the_placeholders(): void + { + $dv = json_decode(file_get_contents(__DIR__ . '/../../lang/dv.json'), true); + + foreach ($dv as $english => $dhivehi) { + preg_match_all('/:[a-z_]+/', $english, $expected); + preg_match_all('/:[a-z_]+/', $dhivehi, $actual); + + $this->assertEqualsCanonicalizing(array_unique($expected[0]), array_unique($actual[0]), "Placeholders differ in: {$english}"); + } + } + + /** @return array */ + protected function strings(): array + { + $strings = [...array_values(Strings::ALL), ...array_values(MobileNumberPolicy::DESCRIPTIONS)]; + + foreach (['src', 'resources/views'] as $folder) { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . "/../../{$folder}")); + + foreach ($files as $file) { + if (! $file->isFile() || ! str_ends_with($file->getFilename(), '.php')) { + continue; + } + + preg_match_all("/(?:__|trans_choice)\\(\\s*'((?:[^'\\\\]|\\\\.)*)'/", file_get_contents($file->getPathname()), $matches); + + foreach ($matches[1] as $match) { + $strings[] = stripslashes($match); + } + + preg_match_all('/(?:__|trans_choice)\(\s*"((?:[^"\\\\]|\\\\.)*)"/', file_get_contents($file->getPathname()), $matches); + + foreach ($matches[1] as $match) { + $strings[] = stripslashes($match); + } + } + } + + return array_values(array_unique($strings)); + } +} diff --git a/tests/Unit/UserMobileNumberTest.php b/tests/Unit/UserMobileNumberTest.php new file mode 100644 index 0000000..effecbd --- /dev/null +++ b/tests/Unit/UserMobileNumberTest.php @@ -0,0 +1,106 @@ +withMobileNumber('7771234')->create(); + + $this->assertTrue($user->hasVerifiedMobile()); + $this->assertTrue($user->phone_verified); + $this->assertFalse($user->needs_phone_verification); + $this->assertSame('7771234', $user->mobile_number); + $this->assertSame('960', $user->mobile_number_country_code); + $this->assertSame('7771234', $user->short_mobile_number); + $this->assertSame('+9607771234', $user->routeNotificationForSms()); + $this->assertSame('+9607771234', $user->routeNotificationForDhiraagu()); + + $this->assertTrue(User::query()->phoneVerified()->whereKey($user->getKey())->exists()); + $this->assertTrue(User::query()->hasMobileNumber('7771234')->exists()); + $this->assertTrue(User::query()->hasShortMobileNumber('+9607771234')->exists()); + $this->assertTrue(User::query()->hasPhone('777')->exists()); + $this->assertTrue(User::findByMobileNumber('07771234')->is($user)); + $this->assertNull(Admin::findByMobileNumber('7771234')); + } + + #[Test] + public function users_without_a_number_need_to_verify_one(): void + { + $user = User::factory()->create(); + + $this->assertFalse($user->hasVerifiedMobile()); + $this->assertTrue($user->needs_phone_verification); + $this->assertNull($user->formatted_mobile_number); + $this->assertNull($user->routeNotificationForSms()); + $this->assertTrue(User::query()->phoneUnverified()->whereKey($user->getKey())->exists()); + } + + #[Test] + public function updating_the_number_replaces_the_old_one_and_fires_an_event(): void + { + Event::fake([MobileNumberUpdated::class]); + + $user = User::factory()->withMobileNumber('7771234')->create(); + $admin = Admin::factory()->create(); + + $this->assertNotNull($user->updateMobileNumber('9991234', causer: $admin)); + + $this->assertSame('+9609991234', $user->formatted_mobile_number); + $this->assertNull(MobileNumber::query()->hasNumber('7771234')->first()->user_id); + + Event::assertDispatched(MobileNumberUpdated::class, fn (MobileNumberUpdated $event) => $event->old_number === '+9607771234' + && $event->new_number === '+9609991234' + && $event->user->is($user) + && $event->causer->is($admin)); + } + + #[Test] + public function a_number_of_another_user_is_not_taken(): void + { + User::factory()->withMobileNumber('7771234')->create(); + $user = User::factory()->create(); + + $this->assertNull($user->updateMobileNumber('7771234')); + $this->assertFalse($user->fresh()->hasVerifiedMobile()); + } + + #[Test] + public function removing_the_number(): void + { + Event::fake([MobileNumberUpdated::class]); + + $user = User::factory()->withMobileNumber('7771234')->create(); + + $user->removeMobileNumber(); + + $this->assertFalse($user->hasVerifiedMobile()); + $this->assertSame(1, MobileNumber::query()->count()); + + Event::assertDispatched(MobileNumberUpdated::class, fn (MobileNumberUpdated $event) => $event->new_number === null); + } + + #[Test] + public function force_deleting_a_user_deletes_the_number_but_soft_deleting_keeps_it(): void + { + $user = User::factory()->withMobileNumber('7771234')->create(); + + $user->delete(); + + $this->assertSame(1, MobileNumber::query()->count()); + + $user->forceDelete(); + + $this->assertSame(0, MobileNumber::query()->count()); + } +} diff --git a/tests/Unit/migrations/CreateMobileNumbersTableTest.php b/tests/Unit/migrations/CreateMobileNumbersTableTest.php deleted file mode 100644 index 7064d1c..0000000 --- a/tests/Unit/migrations/CreateMobileNumbersTableTest.php +++ /dev/null @@ -1,29 +0,0 @@ -withoutExceptionHandling(); - - $this->assertTrue(Schema::hasTable('mobile_numbers')); - $this->assertTrue(Schema::hasColumn('mobile_numbers', 'user_type')); - $this->assertTrue(Schema::hasColumn('mobile_numbers', 'user_id')); - $this->assertTrue(Schema::hasColumn('mobile_numbers', 'attempts')); - $this->assertTrue(Schema::hasColumn('mobile_numbers', 'number')); - $this->assertTrue(Schema::hasColumn('mobile_numbers', 'country_code')); - $this->assertTrue(Schema::hasColumn('mobile_numbers', 'verification_code')); - $this->assertTrue(Schema::hasColumn('mobile_numbers', 'verification_code_created_at')); - $this->assertTrue(Schema::hasColumn('mobile_numbers', 'verification_code_id')); - } -} diff --git a/tests/database/2024_01_01_000000_create_test_tables.php b/tests/database/2024_01_01_000000_create_test_tables.php new file mode 100644 index 0000000..c2fe00a --- /dev/null +++ b/tests/database/2024_01_01_000000_create_test_tables.php @@ -0,0 +1,44 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('personal_access_tokens', function (Blueprint $table) { + $table->id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('admins'); + Schema::dropIfExists('users'); + } +}; diff --git a/tests/js/fixtures.ts b/tests/js/fixtures.ts new file mode 100644 index 0000000..f2ebe02 --- /dev/null +++ b/tests/js/fixtures.ts @@ -0,0 +1,32 @@ +import strings from "./strings.dv.json"; +import type { MobilePageProps } from "../../resources/js/types"; + +export { strings }; + +export const urls = { + home: "/dashboard", + login: "/login", + "login.store": "/login", + logout: "/logout", + "profile.edit": "/settings/profile", + "security.edit": "/settings/security", + "login.mobile": "/login/mobile", + "login.mobile.store": "/login/mobile", + "register.mobile": "/register/mobile", + "register.mobile.store": "/register/mobile", + "mobile-number": "/settings/mobile-number", + "mobile-number.store": "/settings/mobile-number", +}; + +export const page: MobilePageProps = { + auth_type: { name: "user", features: { registration: true }, two_factor: "optional", username: "email", login_methods: [] }, + urls, + account_links: [ + { key: "profile", label: "ޕްރޮފައިލް", url: "/settings/profile", active: false }, + { key: "security", label: "ސެކިއުރިޓީ", url: "/settings/security", active: false }, + { key: "mobile-number", label: "މޯބައިލް ނަންބަރު", url: "/settings/mobile-number", active: true }, + ], + strings, + status: null, + mobile: { country_codes: { "960": "+960" }, default_country_code: "960", number_prefix: "+", code_length: 6 }, +}; diff --git a/tests/js/pages.test.tsx b/tests/js/pages.test.tsx new file mode 100644 index 0000000..2206b86 --- /dev/null +++ b/tests/js/pages.test.tsx @@ -0,0 +1,138 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import Login from "../../resources/js/Pages/Login"; +import Register from "../../resources/js/Pages/Register"; +import RegisterDetails from "../../resources/js/Pages/RegisterDetails"; +import MobileNumber from "../../resources/js/Pages/Settings/MobileNumber"; +import Verify from "../../resources/js/Pages/Verify"; +import { page, strings } from "./fixtures"; + +describe("Login", () => { + it("asks for the number with the only country code as a prefix, in Dhivehi", () => { + render(); + + expect(screen.getByText(strings.log_in_with_mobile)).toBeTruthy(); + + const number = document.querySelector('form[action="/login/mobile"] input[name="number"]'); + expect(number?.getAttribute("type")).toBe("tel"); + expect(number?.getAttribute("dir")).toBe("ltr"); + expect(document.querySelector('input[name="country_code"]')?.value).toBe("960"); + expect(screen.getByText("+960")).toBeTruthy(); + expect(screen.getByText(strings.sign_up).getAttribute("href")).toBe("/register/mobile"); + expect(document.querySelector("[data-password-login]")?.getAttribute("href")).toBe("/login"); + }); + + it("shows a country code select when there is a choice", () => { + render(); + + expect(document.querySelector('select[name="country_code"]')).toBeTruthy(); + expect(screen.queryByText(strings.sign_up)).toBeNull(); + }); +}); + +describe("Register", () => { + it("asks for the number and links to the mobile login", () => { + render(); + + expect(document.querySelector('form[action="/register/mobile"] input[name="number"]')).toBeTruthy(); + expect(screen.getByText(strings.log_in).getAttribute("href")).toBe("/login/mobile"); + }); +}); + +describe("Verify", () => { + const props = { + ...page, + purpose: "login" as const, + title: "ލޮގިން ވުމަށް ކޯޑު ޖައްސަވާ", + mobile_number: { formatted_number: "+9607771234", expires_in: 600, resend_in: 30, remaining_attempts: 5 }, + verify_url: "/login/mobile/verify", + resend_url: "/login/mobile/resend", + change_url: "/login/mobile", + }; + + it("asks for the code and keeps the number left to right", () => { + render(); + + const code = document.querySelector('form[action="/login/mobile/verify"] input[name="verification_code"]'); + expect(code?.getAttribute("autocomplete")).toBe("one-time-code"); + expect(code?.getAttribute("inputmode")).toBe("numeric"); + expect(code?.getAttribute("maxlength")).toBe("6"); + + expect(document.querySelector("bdi")?.textContent).toBe("+9607771234"); + expect(document.querySelector("bdi")?.getAttribute("dir")).toBe("ltr"); + expect(document.querySelector('form[action="/login/mobile/resend"]')).toBeTruthy(); + expect(document.querySelector("[data-change-number]")?.getAttribute("href")).toBe("/login/mobile"); + expect(document.querySelector("[data-resend-in]")?.textContent).toContain("30"); + expect(document.querySelector('[data-mobile-page="verify"]')).toBeTruthy(); + }); + + it("uses the account layout when changing a number", () => { + render(); + + expect(document.querySelector('[data-auth-page="mobile-number-verify"]')).toBeTruthy(); + expect(document.querySelector("[data-account-nav]")).toBeTruthy(); + expect(document.querySelector("[data-resend-in]")).toBeTruthy(); + }); + + it("hides the countdown when a new code can be sent", () => { + render(); + + expect(document.querySelector("[data-resend-in]")).toBeNull(); + }); +}); + +describe("RegisterDetails", () => { + it("renders the stencil form", () => { + render( + , + ); + + expect(document.querySelector('form[action="/register/mobile/details"] input[name="name"]')).toBeTruthy(); + expect(document.querySelector('input[name="email"]')).toBeTruthy(); + expect(screen.getByText("އެކައުންޓް ހަދާ")).toBeTruthy(); + expect(document.querySelector("bdi")?.textContent).toBe("+9607771234"); + }); +}); + +describe("Settings/MobileNumber", () => { + it("shows the current number and a form for a new one", () => { + render(); + + expect(document.querySelector("[data-current-number] bdi")?.textContent).toBe("+9607771234"); + expect(screen.getByText(strings.new_mobile_number)).toBeTruthy(); + expect(document.querySelector('form[action="/settings/mobile-number"] input[name="number"]')).toBeTruthy(); + expect(document.querySelector("[data-account-nav]")).toBeTruthy(); + }); + + it("shows the account links, this page among them", () => { + render(); + + const link = document.querySelector('[data-account-nav] a[href="/settings/mobile-number"]'); + expect(link?.textContent).toBe("މޯބައިލް ނަންބަރު"); + expect(link?.getAttribute("aria-current")).toBe("page"); + expect(document.querySelector('[data-account-nav] a[href="/settings/profile"]')).toBeTruthy(); + }); + + it("warns users without a number", () => { + render(); + + expect(document.querySelector("[data-no-number]")?.textContent).toBe(strings.no_number); + }); +}); diff --git a/tests/js/raw.d.ts b/tests/js/raw.d.ts new file mode 100644 index 0000000..3520f9a --- /dev/null +++ b/tests/js/raw.d.ts @@ -0,0 +1,5 @@ +// stencil's renderer imports its stylesheet with Vite's ?raw suffix +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/tests/js/setup.ts b/tests/js/setup.ts new file mode 100644 index 0000000..01bd2e2 --- /dev/null +++ b/tests/js/setup.ts @@ -0,0 +1,16 @@ +import "@testing-library/jest-dom/vitest"; + +// jsdom has no matchMedia; Onu's components use it for reduced motion +if (!window.matchMedia) { + window.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList; +} diff --git a/tests/js/strings.dv.json b/tests/js/strings.dv.json new file mode 100644 index 0000000..4c12232 --- /dev/null +++ b/tests/js/strings.dv.json @@ -0,0 +1,33 @@ +{ + "account": "އެކައުންޓް", + "back_to_login": "ޕާސްވޯޑާއެކު ލޮގިން ކުރޭ", + "change_number": "ނަންބަރު ބަދަލުކުރޭ", + "code": "ކޯޑު", + "code_help": ":number އަށް :length ދިޖިޓުގެ ކޯޑެއް ފޮނުވީމެވެ. މި ކޯޑުގެ މުއްދަތު :minutes މިނެޓުން ހަމަވާނެއެވެ.", + "continue": "ކުރިއަށް", + "country_code": "ޤައުމީ ކޯޑު", + "create_account": "އެކައުންޓް ހަދާ", + "current_number": "ތިޔަ ފަރާތުގެ މޯބައިލް ނަންބަރަކީ :number.", + "have_account": "ކުރިން އެކައުންޓެއް ހަދާފައިވޭތޯ؟", + "log_in": "ލޮގިން ކުރޭ", + "log_in_with_mobile": "މޯބައިލް ނަންބަރާއެކު ލޮގިން ކުރޭ", + "log_out": "ލޮގްއައުޓް ކުރޭ", + "mobile_number": "މޯބައިލް ނަންބަރު", + "mobile_number_help": "އެސްއެމްއެސްއިން ކޯޑެއް ފޮނުވާނަމެވެ.", + "new_mobile_number": "އައު މޯބައިލް ނަންބަރު", + "no_account": "އެކައުންޓެއް ނެތްތޯ؟", + "no_number": "ތިޔަ ފަރާތުން އަދި މޯބައިލް ނަންބަރެއް ވެރިފައި ނުކުރައްވައެވެ.", + "profile": "ޕްރޮފައިލް", + "register_details_help": "ތިޔަ ފަރާތުގެ ނަންބަރު :number ވެރިފައި ކުރެވިއްޖެއެވެ. ތިޔަ ފަރާތާ ބެހޭ މަޢުލޫމާތު ދެއްވާ.", + "register_details_title": "ތިޔަ ފަރާތުގެ މަޢުލޫމާތު", + "register_with_mobile": "މޯބައިލް ނަންބަރާއެކު ސައިން އަޕް ކުރޭ", + "remember_me": "ހަނދާންބަހައްޓާ", + "resend_code": "އައު ކޯޑެއް ފޮނުވާ", + "resend_in": ":seconds ސިކުންތު ފަހުން އައު ކޯޑެއް ހޯދިދާނެއެވެ.", + "security": "ސެކިއުރިޓީ", + "send_code": "ކޯޑު ފޮނުވާ", + "settings_mobile_help": "ތިޔަ ފަރާތުގެ މޯބައިލް ނަންބަރު ބޭނުންކުރެވެނީ ލޮގިން ވުމަށާއި އެސްއެމްއެސް ނޮޓިފިކޭޝަންތަކަށެވެ.", + "settings_mobile_title": "މޯބައިލް ނަންބަރު", + "sign_up": "ސައިން އަޕް ކުރޭ", + "verify": "ވެރިފައި ކުރޭ" +} diff --git a/tests/passport-keys/oauth-private.key b/tests/passport-keys/oauth-private.key deleted file mode 100644 index 7c0e653..0000000 --- a/tests/passport-keys/oauth-private.key +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQCfJoQmGnmGrcfn -r/vRh1QvcWf82JzkVm40lUTF3M9el+iuYy69bgFqE4DLfow9SlPE/U1JU7BH6dmD -sWV3WUNM0qyis0pV6SDILsg4vo9sNvjYvR6/UtpDILzMe+7Sovbquzn4UYAR0oyQ -T/+coXQuKSxzwxB4+HhrR94OTs9BCAqO15zlgzx/k3zU5QAl/HdXp78WUi5cwOID -8vp9PF7WRthScfeYJ2MI+O29mWVDbjozsWffPugqBFA8l8IEx2VcgUbnOIKkRym4 -ahSjLfY17fHr6piwBgDPxN1jUznHxc6Vhl9Qymb63BTanIdK6J7Zj57+UU7+pr+b -Bs4LScMr0M3yVtUg4CWemmWmpAqbSLZOWY9BpQ64fryVcshQ387bwzr8yOPDS3RS -J4BEBQWnulvO8ffGK4Yzy+H0JWQ/RMNLuzcYJRuSlrburMi6+pqkUPPkOwUQfD1/ -aebBswzscuh95X+D3zrVeu2VuWPTigJX6q8XeQh57EtE0kn/FTHXZqth3sN7FB7A -IjzXiKkIlx6eLGpKx/f541KIA/1tf432S6fH8WETj2qlrh+He3ZKC2PhFutuLSi8 -9P8I+kZHlUWSQWmUjytH8EUezuhwChP+ijjx9ejOfXmjnlYwiEzkGQD14Xz7+gUM -3XYZGBrNRcf/xD7EhaxGcxjpM0azswIDAQABAoICABJSNEdQp+nNqqQs2a/qxNG7 -W+27IACXRJOQFX0zqLmqgjJFH7XV6prECQEkHc/WdSNJx7JnzHTS4mPrMZOWNo2q -+Q231xhhwJDa9di2cztRbr9Asj/zMUYmCCYAJGOiOAuYpxlDhJtIoj7gvrvQyFei -DMh8zbInZHFxMgpynyBM5S5BewHe5EGJgIbfn+8/i5uPJ4vRxR2PZNVwjM6K+x3u -xOtkzEx7SEbZpUC83m/vSgr2Ys9L5h0kJvp6u+6h57TI+xkWI8/2k0J0D9EjMuNN -c5qlhJNK9Z/1eUj8ZTvutRP4hpN94XICkPIDr/17HwJLqtF7Gb1uA8O8ek7JXIrJ -wyA9srxGlRTqEjfZ1Vidg1n8zvQLS5nb2sDD+haVda+k5m/KpICZy4beYxd4QIDk -m7QO0kLY7AF7ZVt7shiX5M7RJiaUqvdpeFMkMhYKUxqYgLd+MeB6+8OUtsgk4ZaB -PFr0fuEr6HdkCHQXYAfp7YIzcllayvFYOCyZAluRVbxpHHhh+R9d+HiZJlexsJZh -X4K+8oqOlDbVyNcBcutyxCqTrphgP5GOocN8AGviE9uXgzI+kfKQ+kpGLkhdSibC -TBr24nGYmr3cXJBs8vC0eAB3KuxtkqoGq+vm6CD/YBTxnhrmFXgOSknvUbdhtJ0O -N7sMBGqtPj9a8pRxv0R1AoIBAQDKHMNqRmGsoYjuEgcZpAywXE+pIXM2fgxxwm8H -pNR8hQ1oXA2pfjPZoVJT6QStXTKkbSaxGgbzx+UsmJgJaT3k4QPflSewgWhgb4Q7 -oD/sehZsFWDPrYBiA8PDh27XQkdMthgVP6HSTWV7+FSPjmlKs2ZBd+7jZP9aBT2W -KTyGeVcZ3BBs4zOmIzZc5lArNCT5mO/iqvvVN2mdl3U2UQf2+ESMHA3PEmtcx1Q7 -MFFHsVDBpO1jR87XNKRrO97re5jcBeAfN0KvXseVfspItGfFyUY699E4DIPUoMF1 -Hck/bj4vDdnC/ShNLNtwmJlREQtIIBxe9T/oecAbU27xZtxNAoIBAQDJlWA7tG3j -rcUuZoamVkEiFSnuGjPysQEI73lxO//scwNR5KDODT55B8q+KgIZt1CN2D1Q2rRN -afjuJmWgG+jb6KLDY6Y36cib2KzHRRPNmb7AotxtCwX4UbQrK39SJ88uEub/Z1cb -LJvWCpObZmE0sLb1zF1CV6cvu//De/dzci+MC9GxWIFqj5ke+K2a9zyNbNarKAW8 -YklSE3kzTWP3YsSI5Hlp/ZDuPLQunZNCwlry4x1gzftcpJoBtcbN4RfLurlyPspd -eLUdh66UTnog8dZZwpebBrQKfQKSaJK8A+ldKgLiYFVNuK34EO7oTwEPJ68mH727 -sBY5kvkz68//AoIBACn0NoxE99mB3o6sp1SnPQsO3mpZSzUwjRzAI5QOnx0J8XKM -STm5vMGcPx6z7Aigo/aFwdZZdL/96pCLTGQHuJVeMBOlPovhEO2TuHs4FIzd+vGM -rn/toAy4g1Z/Mn70fyBpXC5s+wZQkpRdRBBYm3qqKrvETY5eVLM3C0W0YZ/4aCAy -EibIMtx1yr8V/+Y3+MTm/kSrAYOM8RuJaQdRYrndZz0+RY+KX0bvCYkPyo97Oyw3 -NB+7tC/v+YFY+DkheuWRQqi4K9uDbSKXIx5cdH4V/cIX1xIvZt4oUIAjIrdMBxR1 -Z5KWQltiyFpjEtBFHXZnU/xSJGX5f+ssModhtrECggEAVT0UtCpXTS7tcfFofm16 -nS7VzBjIXpb6GQ0qDPeSACmh/b7vQDHZeNQSptacjMM3DMOjKc1Q8GsfF5Sk5E7o -6BQ2vugkIQB6E2xLovdSqF2ai1pSWsmOfLIPvTFnZRXwRUs44kaQTq1ON/PA9rUT -5XPT6ZvA04y85TJZtA93Uxm1gUJ+KL+D+cs+tBnzTtO8hxC+tSD3S/FDj7EsTsUq -wDDDah8yN99AqXyBtDRtUI1eZ9YhVg3DfCkd9hsnNWc46KzMPcLPTzb7XsFA0PVe -LxAT02C1OphbCRRTQhV19oqw4vQgxBh3+aVd6sc+Pn0ptX7SUQya7gJHY5ECTzIn -gQKCAQALESfJ1u156hWzr6v24KtCMnaRMGTE79ef+O/wg5rFJ5KvqSFB9UpToAo8 -1J9Vio2AKNO45oJ42zpM/feqkKvnLEqXQEvysZUwNuD1PdQVVXfmBO9jpZ6ZaUd6 -5ylU5URLHDDmsPoKB5fZXyswVE8j5rEcooy0qXTVNeg/9FdHWknm9dpfIMykGnuM -miqnVaAqUKesED66gDclxR6Ywoxo2yPVJ7SIMyB/0a3AOc13kSTKt7iWzk7lKLSY -CU6srZNiJYmKNJueRDurfobOglbhquJzBA1j6pHdI59blLR7IKliTN8U4GbKUjNz -SiP9jEBD9YWj8X5IqxaDOowSUEAA ------END PRIVATE KEY----- \ No newline at end of file diff --git a/tests/passport-keys/oauth-public.key b/tests/passport-keys/oauth-public.key deleted file mode 100644 index a9aa2b6..0000000 --- a/tests/passport-keys/oauth-public.key +++ /dev/null @@ -1,14 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnyaEJhp5hq3H56/70YdU -L3Fn/Nic5FZuNJVExdzPXpformMuvW4BahOAy36MPUpTxP1NSVOwR+nZg7Fld1lD -TNKsorNKVekgyC7IOL6PbDb42L0ev1LaQyC8zHvu0qL26rs5+FGAEdKMkE//nKF0 -Liksc8MQePh4a0feDk7PQQgKjtec5YM8f5N81OUAJfx3V6e/FlIuXMDiA/L6fTxe -1kbYUnH3mCdjCPjtvZllQ246M7Fn3z7oKgRQPJfCBMdlXIFG5ziCpEcpuGoUoy32 -Ne3x6+qYsAYAz8TdY1M5x8XOlYZfUMpm+twU2pyHSuie2Y+e/lFO/qa/mwbOC0nD -K9DN8lbVIOAlnpplpqQKm0i2TlmPQaUOuH68lXLIUN/O28M6/Mjjw0t0UieARAUF -p7pbzvH3xiuGM8vh9CVkP0TDS7s3GCUbkpa27qzIuvqapFDz5DsFEHw9f2nmwbMM -7HLofeV/g9861Xrtlblj04oCV+qvF3kIeexLRNJJ/xUx12arYd7DexQewCI814ip -CJcenixqSsf3+eNSiAP9bX+N9kunx/FhE49qpa4fh3t2Sgtj4Rbrbi0ovPT/CPpG -R5VFkkFplI8rR/BFHs7ocAoT/oo48fXozn15o55WMIhM5BkA9eF8+/oFDN12GRga -zUXH/8Q+xIWsRnMY6TNGs7MCAwEAAQ== ------END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/views/app.blade.php b/tests/views/app.blade.php new file mode 100644 index 0000000..6f1207c --- /dev/null +++ b/tests/views/app.blade.php @@ -0,0 +1 @@ +@inertia diff --git a/tests/views/custom-mobile-login.blade.php b/tests/views/custom-mobile-login.blade.php new file mode 100644 index 0000000..9544438 --- /dev/null +++ b/tests/views/custom-mobile-login.blade.php @@ -0,0 +1 @@ +custom mobile login for {{ $type }} diff --git a/tests/views/layouts/app.blade.php b/tests/views/layouts/app.blade.php new file mode 100644 index 0000000..e1ffb5d --- /dev/null +++ b/tests/views/layouts/app.blade.php @@ -0,0 +1,7 @@ + + +{{ config('app.name') }} + +@yield('content') + + diff --git a/tests/views/overrides/login.blade.php b/tests/views/overrides/login.blade.php new file mode 100644 index 0000000..b45d2e9 --- /dev/null +++ b/tests/views/overrides/login.blade.php @@ -0,0 +1 @@ +overridden mobile login diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f3bee18 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vitest/globals"], + "paths": { + "@javaabu/mobile-verification": ["./resources/js"], + "@javaabu/mobile-verification/*": ["./resources/js/*"], + "@javaabu/auth": ["./vendor/javaabu/auth/resources/js"], + "@javaabu/auth/*": ["./vendor/javaabu/auth/resources/js/*"], + "@javaabu/stencil": ["./vendor/javaabu/stencil/resources/js"], + "@javaabu/stencil/*": ["./vendor/javaabu/stencil/resources/js/*"] + }, + "resolveJsonModule": true + }, + "include": ["resources/js", "tests/js"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7645e71 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +// @javaabu/auth and @javaabu/stencil are those packages' React code in vendor/ (composer install first), +// as javaabu/manifest's Vite plugin resolves them in apps +export default defineConfig({ + resolve: { + alias: { + "@javaabu/mobile-verification": new URL("./resources/js", import.meta.url).pathname, + "@javaabu/auth": new URL("./vendor/javaabu/auth/resources/js", import.meta.url).pathname, + "@javaabu/stencil": new URL("./vendor/javaabu/stencil/resources/js", import.meta.url).pathname, + }, + }, + test: { + environment: "jsdom", + globals: true, + include: ["tests/js/**/*.test.{ts,tsx}"], + setupFiles: ["tests/js/setup.ts"], + }, +});