diff --git a/.gitattributes b/.gitattributes index bb6265e..f954f98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,19 @@ # Path-based git attributes # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html -# Ignore all test and documentation with "export-ignore". +# Keep development files out of the Composer package. docs, llms.txt and +# resources/boost ship with it, for AI assistants and Laravel Boost. /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore +/.github export-ignore +/.editorconfig 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 +/node_modules export-ignore +/package.json export-ignore +/pnpm-lock.yaml export-ignore +/tsconfig.json export-ignore +/vitest.config.ts export-ignore diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index a02bb76..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,54 +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 ] - laravel: [ 13.*, 12.* ] - stability: [ prefer-lowest, prefer-stable ] - include: - - laravel: 13.* - testbench: 11.* - carbon: ^3.8.4 - - laravel: 12.* - testbench: 10.* - carbon: ^3.8.4 - - 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: none\ - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: vendor/bin/phpunit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..dd028b2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,169 @@ +name: tests + +on: + pull_request: + push: + branches: + - main + - next-gen + +# 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 + + - 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 + + without-translatable: + runs-on: ubuntu-latest + + name: Without javaabu/translatable + + 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 translatable + run: | + composer remove --dev javaabu/translatable --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit --testsuite Package + + activitylog-v4: + runs-on: ubuntu-latest + + name: spatie/laravel-activitylog v4 + + 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 + + # The matrix's prefer-stable legs install v5 and its prefer-lowest legs v4.10; + # this keeps the latest v4 covered + - name: Install dependencies with activitylog v4 + run: | + composer require --dev "spatie/laravel-activitylog:^4.10" --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit + + 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: Show the activitylog version + run: composer show spatie/laravel-activitylog | grep versions + + - 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 6cc69e4..d37c91e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ vendor coverage .phpunit.result.cache .idea +node_modules +.phpunit.cache diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index df16b68..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,19 +0,0 @@ -filter: - excluded_paths: [tests/*] - -checks: - php: - remove_extra_empty_lines: true - remove_php_closing_tag: true - remove_trailing_whitespace: true - fix_use_statements: - remove_unused: true - preserve_multiple: false - preserve_blanklines: true - order_alphabetically: true - fix_php_opening_tag: true - fix_linefeed: true - fix_line_ending: true - fix_identation_4spaces: true - fix_doc_comments: true - diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index f4d3cbc..0000000 --- a/.styleci.yml +++ /dev/null @@ -1,4 +0,0 @@ -preset: laravel - -disabled: - - single_class_element_per_statement diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9565421 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# permissions: notes for AI coding agents + +`javaabu/permissions` wraps spatie/laravel-permission with a registration API for permissions, roles and policies, multi-auth helpers, and role management (routes, controller, JSON API, Blade and Inertia pages). Other Javaabu packages depend on its registration API, so treat `Permissions`, `PermissionGroup`, `ResourcePolicy` and `Route::roles()` as public contracts. + +## Layout + +| Path | What | +| --- | --- | +| `src/Permissions.php` | Facade over `PermissionRegistry`, plus `sync()`, `ignoreMigrations()`, `permissionName()` | +| `src/Registry` | `PermissionRegistry` (groups, roles, policies, guards), `PermissionGroup`, `RoleDefinition` | +| `src/Sync` | `PermissionSyncer` and `SyncResult`, used by `permissions:sync` and `PermissionsSeeder` | +| `src/Policies` | `ResourcePolicy` base class and `RolePolicy` | +| `src/Traits/HasRoles.php` | Trait for user models (includes spatie's) | +| `src/Models` | `Role` and `Permission`, extending spatie's | +| `src/Routing/RoleRoutes.php`, `src/Http` | `Route::roles()`, `RolesController`, `RoleRequest`, resources | +| `src/Views/ViewRenderer.php` | Renders Blade (`permissions::roles.index`) or Inertia (`permissions::Roles/Index`) | +| `resources/views/roles` | Blade pages, built with javaabu/forms v2 components | +| `resources/js` | Inertia React pages (`Pages/Roles`), components (`@javaabu/permissions`) and the Blade script (`roles.ts`), registered with javaabu/manifest | +| `src/Support/Translations.php`, `src/Models/Translatable` | Optional javaabu/translatable support for role descriptions | +| `config/permissions.php` | Package options; `config/permission.php` is spatie's, preconfigured | +| `database` | Migrations, factories and the seeder (`Javaabu\Permissions\Database\…`) | +| `lang/dv.json` | Dhivehi; English strings are the keys | +| `docs`, `llms.txt` | Docusaurus pages for docs.javaabu.com and the index for AI assistants | +| `resources/boost` | Laravel Boost guideline and skill, shipped in the package | +| `tests` | Testbench tests; two guards (`web` → `User`, `web_admin` → `Admin`). `tests/Translatable` needs javaabu/translatable (suite `Translatable`); `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]` and `it_…` test names. Pint enforces formatting. +- Nothing may assume the `web` guard or `App\Models\User`. Type-hint users as `Model`, and test behaviour on both guards. +- Registration must not touch the database; only the syncer writes. +- Package policies are applied after boot and must never replace a policy already on the gate, except from `config('permissions.policies')`. +- No hard dependencies on javaabu/helpers, javaabu/activitylog, javaabu/translatable or Inertia. Optional packages are feature-detected and tested both ways; CI runs the `Package` suite with translatable removed. +- Every page exists in Blade and React with the same props and behaviour. Use logical utilities only (`ms-`, `text-start`); a test rejects `ml-`, `text-left` and the like in Blade views. Component tags of optional packages (``) go in their own partial. +- Every user-facing string goes through `__()` with an English key, and gets a Dhivehi line in `lang/dv.json`. Use Thaana in tests of user-facing behaviour. List uncertain translations in `docs/advanced-usage/translations.md`. +- 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 +pnpm check # typecheck and Vitest +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8869e77..b8878f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ # Changelog -All notable changes will be documented in this file +All notable changes will be documented in this file. See [docs/upgrading.md](docs/upgrading.md) for moving from v2. -## 1.0.0 - 202X-XX-XX +## 3.0.0 - unreleased -- initial release +- Permission registration API: `Permissions::register()`, `resource()`, `role()`, grouped permissions with translatable descriptions and labels +- `php artisan permissions:sync` (with `--guard` and `--prune`), `PermissionsSeeder` and `Permissions::sync()` +- Overridable package policies: `Permissions::policy()`, `config('permissions.policies')`, and `Gate::policy()` in the app +- `ResourcePolicy` base class with custom abilities, ownership and guard support; `RolePolicy` +- Multi-auth: `permissions.guards`, per-group and per-role guards, `Permissions::guardFor()`, `Permissions::check()`, guard-aware `anyPermission()` and `@anypermission` +- Role management: `Route::roles()`, an overridable `RolesController` with a JSON API +- Role pages in Blade (javaabu/forms v2) and Inertia React (@javaabu/forms, shipped through javaabu/manifest), right-to-left ready, with select all per permission group; `permissions.layout` and `layout_section` options +- npm dependencies (`@javaabu/forms`, `@javaabu/onu`, React, `@inertiajs/react`) are declared for the React pages only, so `php artisan manifest:check` doesn't ask Blade apps for them +- React components for apps: `RoleForm`, `PermissionGroupFields`, `RoleActions` from `@javaabu/permissions` +- `RoleResource` includes `can`, `links` and `permissions_count` +- Optional javaabu/translatable support for role descriptions: `Models\Translatable\Role`, `Support\Translations`, and a migration adding `translations` and `lang` to the roles table +- Dhivehi translations +- Activity logging is optional, through spatie/laravel-activitylog v4 or v5; on v5 the changes go in `attribute_changes` +- Requires PHP 8.4, Laravel 12 or 13, javaabu/forms v2 and javaabu/manifest +- Breaking: no longer requires javaabu/helpers or javaabu/activitylog; `Role` no longer implements `AdminModel` or uses `LogsActivity`; factories moved to `Javaabu\Permissions\Database\Factories`; `updateRole()` no longer reads `get_setting()` 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/README.md b/README.md index b6d9c38..4326d84 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,82 @@ # Permissions -[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/permissions.svg?style=flat-square)](https://packagist.org/packages/javaabu/permissions) -[![Test Status](../../actions/workflows/run-tests.yml/badge.svg)](../../actions/workflows/run-tests.yml) -[![Quality Score](https://img.shields.io/scrutinizer/g/javaabu/permissions.svg?style=flat-square)](https://scrutinizer-ci.com/g/javaabu/permissions) -[![Total Downloads](https://img.shields.io/packagist/dt/javaabu/permissions.svg?style=flat-square)](https://packagist.org/packages/javaabu/permissions) +[![Tests](https://github.com/Javaabu/permissions/actions/workflows/tests.yml/badge.svg?branch=next-gen)](https://github.com/Javaabu/permissions/actions/workflows/tests.yml?query=branch%3Anext-gen) +[![codecov](https://codecov.io/gh/Javaabu/permissions/branch/next-gen/graph/badge.svg)](https://codecov.io/gh/Javaabu/permissions/tree/next-gen) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/permissions.svg)](https://packagist.org/packages/javaabu/permissions) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) -Modifications brought upon spatie/laravel-permissions +Roles, permissions and policies for Laravel apps and packages, built on [spatie/laravel-permission](https://spatie.be/docs/laravel-permission). Apps and packages register permissions, roles and default policies in code, and one command writes them to the database. Permissions are grouped and translatable, policies are overridable, everything works per guard with any number of user models, and roles come with routes, a JSON API and Blade ([javaabu/forms](https://github.com/Javaabu/forms)) or Inertia React ([@javaabu/forms](https://github.com/Javaabu/react-forms)) pages that work in Dhivehi and right to left. Role descriptions can be translated with [javaabu/translatable](https://github.com/Javaabu/translatable). +```php +use Javaabu\Permissions\Permissions; +use Javaabu\Permissions\Policies\ResourcePolicy; + +// in a service provider's boot() +Permissions::resource('exports', 'exports', label: 'Exports'); // view_exports, edit_exports, … +Permissions::policy(Export::class, ExportPolicy::class); // the app can override it + +class ExportPolicy extends ResourcePolicy +{ + protected string $resource = 'exports'; +} +``` + +```bash +php artisan permissions:sync +``` ## Documentation -You'll find the documentation on [https://docs.javaabu.com/permissions](https://docs.javaabu.com/permissions). +You'll find the documentation on [https://docs.javaabu.com/permissions](https://docs.javaabu.com/permissions), and in the [`docs`](docs) folder. Upgrading from v2? Read [`docs/upgrading.md`](docs/upgrading.md). -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. +AI coding assistants can start from [`llms.txt`](llms.txt). The package ships a [Laravel Boost](https://github.com/laravel/boost) skill and guideline, which Boost finds by itself. See [AI assistants](docs/ai-assistants.md). -If you've found a bug regarding security please mail [info@javaabu.com](mailto:info@javaabu.com) instead of using the issue tracker. +## Quick start +```bash +composer require javaabu/permissions +php artisan migrate +``` -## Testing +```php +use Javaabu\Permissions\Traits\HasRoles; + +class User extends Authenticatable +{ + use HasRoles; +} +``` -You can run the tests with +```bash +php artisan permissions:sync +``` + +```php +$user->assignRole('super_admin'); +$user->can('view_roles'); // true +``` + +Manage roles from your admin area: + +```php +Route::middleware('web')->prefix('admin')->name('admin.')->group(function () { + Route::roles(['guard' => 'web_admin']); +}); +``` + +The pages are Blade by default; set `'views' => 'inertia'` in `config/permissions.php` for the React pages. See [Managing roles](docs/basic-usage/managing-roles.md#pages). + +## Testing -``` bash -./vendor/bin/phpunit +```bash +composer test +composer lint +pnpm install && pnpm check # the React pages ``` ## Changelog -Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. ## Contributing diff --git a/composer.json b/composer.json index 73c1a32..241e471 100644 --- a/composer.json +++ b/composer.json @@ -1,30 +1,62 @@ { "name": "javaabu/permissions", - "description": "Modifications brought upon spatie/laravel-permissions", + "description": "Roles, permissions and policies for Laravel apps and packages, built on spatie/laravel-permission", "homepage": "https://github.com/Javaabu/permissions", "license": "MIT", + "keywords": [ + "javaabu", + "laravel", + "permissions", + "roles", + "rbac", + "policies" + ], "authors": [ { "name": "Athfan Khaleel", "email": "athfan@javaabu.com" } ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/Javaabu/forms" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/manifest" + }, + { + "type": "vcs", + "url": "https://github.com/n4ff4h/translatable" + } + ], "require": { - "php": "^8.3", + "php": "^8.4", "illuminate/support": "^12.0 || ^13.0", - "spatie/laravel-permission": "^7.2", - "javaabu/helpers": "^1.70", - "javaabu/activitylog": "^1.5" + "javaabu/forms": "^2.0@dev", + "javaabu/manifest": "^1.0@dev", + "spatie/laravel-permission": "^7.2" }, "require-dev": { + "inertiajs/inertia-laravel": "^2.0", + "javaabu/boost-javaabu-guidelines": "^1.0", + "javaabu/translatable": "^3.0@dev", + "laravel/pint": "^1.24", "orchestra/testbench": "^10.0 || ^11.0", - "phpunit/phpunit": "^12.5.8 || ^13.0.3" + "phpunit/phpunit": "^11.5.3 || ^12.5.8", + "spatie/laravel-activitylog": "^4.10 || ^5.0" + }, + "suggest": { + "spatie/laravel-activitylog": "Logs role and permission changes (^4.10 or ^5.0)", + "inertiajs/inertia-laravel": "Renders the role pages with Inertia React when permissions.views is inertia (^2.0)", + "javaabu/translatable": "Translates role descriptions (^3.0)" }, "autoload": { "psr-4": { "Javaabu\\Permissions\\": "src/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "Javaabu\\Permissions\\Database\\Factories\\": "database/factories/", + "Javaabu\\Permissions\\Database\\Seeders\\": "database/seeders/" } }, "autoload-dev": { @@ -32,9 +64,15 @@ "Javaabu\\Permissions\\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": "2.0-dev" + "dev-next-gen": "3.x-dev" }, "laravel": { "providers": [ @@ -42,6 +80,9 @@ ] } }, + "config": { + "sort-packages": true + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/permission.php b/config/permission.php index 476ac50..6e66d24 100644 --- a/config/permission.php +++ b/config/permission.php @@ -1,5 +1,9 @@ [ @@ -13,7 +17,7 @@ * `Spatie\Permission\Contracts\Permission` contract. */ - 'permission' => Javaabu\Permissions\Models\Permission::class, + 'permission' => Permission::class, /* * When using the "HasRoles" trait from this package, we need to know which @@ -24,7 +28,7 @@ * `Spatie\Permission\Contracts\Role` contract. */ - 'role' => Javaabu\Permissions\Models\Role::class, + 'role' => Role::class, ], @@ -136,7 +140,7 @@ /* * The class to use to resolve the permissions team id */ - 'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class, + 'team_resolver' => DefaultTeamResolver::class, /* * Passport Client Credentials Grant @@ -183,7 +187,7 @@ * When permissions or roles are updated the cache is flushed automatically. */ - 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + 'expiration_time' => DateInterval::createFromDateString('24 hours'), /* * The cache key used to store all permissions. diff --git a/config/permissions.php b/config/permissions.php new file mode 100644 index 0000000..4215415 --- /dev/null +++ b/config/permissions.php @@ -0,0 +1,122 @@ + [], + + /* + |-------------------------------------------------------------------------- + | Roles + |-------------------------------------------------------------------------- + | + | Roles `php artisan permissions:sync` creates for each guard. `permissions` + | is `*` for every permission of the guard (kept in sync), or a list of + | names and patterns such as `view_*`, which are only ever added. + | These override roles defined in code with Permissions::role(). + | + */ + + 'roles' => [ + 'super_admin' => [ + 'description' => 'Super Admin', + 'permissions' => '*', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Default role + |-------------------------------------------------------------------------- + | + | The role HasRoles::updateRole() gives a new user when the person + | creating them isn't allowed to choose a role. + | + */ + + 'default_role' => null, + + /* + |-------------------------------------------------------------------------- + | Policy overrides + |-------------------------------------------------------------------------- + | + | Replace the policy a package registered for a model with your own. + | Example: [Javaabu\Exports\Models\Export::class => App\Policies\ExportPolicy::class] + | + */ + + 'policies' => [], + + /* + |-------------------------------------------------------------------------- + | Activity log + |-------------------------------------------------------------------------- + | + | Log role and permission changes (`role_updated`, `permissions_updated`) + | when spatie/laravel-activitylog is installed. + | + */ + + 'log_activity' => true, + + /* + |-------------------------------------------------------------------------- + | Default permissions + |-------------------------------------------------------------------------- + | + | Register this package's own permissions (view_roles, edit_roles, + | delete_roles) and its policy for the role model. + | + */ + + 'register_defaults' => true, + + /* + |-------------------------------------------------------------------------- + | Views + |-------------------------------------------------------------------------- + | + | Which stack the role pages render with: `blade` (javaabu/forms) or + | `inertia` (@javaabu/forms React pages shipped through javaabu/manifest). + | Requests that expect JSON always get JSON. + | + */ + + 'views' => env('PERMISSIONS_VIEWS', 'blade'), + + /* + | The Blade layout the pages extend, and the section they fill. + */ + + 'layout' => 'layouts.app', + + 'layout_section' => 'content', + + /* + |-------------------------------------------------------------------------- + | Controllers + |-------------------------------------------------------------------------- + | + | The controllers Route::roles() uses. Extend the package's controller to + | change its behaviour. + | + */ + + 'controllers' => [ + 'roles' => RolesController::class, + ], + +]; diff --git a/database/factories/PermissionFactory.php b/database/factories/PermissionFactory.php index 65b3c95..17138e3 100644 --- a/database/factories/PermissionFactory.php +++ b/database/factories/PermissionFactory.php @@ -1,9 +1,9 @@ $suffix, - 'name' => Str::slug("$prefix $suffix", '_'), + 'name' => Str::slug("$prefix $suffix", '_'), 'description' => fake()->sentence, ]; } diff --git a/database/factories/RoleFactory.php b/database/factories/RoleFactory.php index 5b5f3e0..f32e0e4 100644 --- a/database/factories/RoleFactory.php +++ b/database/factories/RoleFactory.php @@ -1,19 +1,28 @@ getRoleClass(); + } + public function definition(): array { return [ - 'name' => Str::slug(fake()->unique()->word(), '_'), + 'name' => Str::slug(fake()->unique()->word(), '_'), 'description' => fake()->sentence, ]; } diff --git a/database/migrations/2026_11_04_000000_add_translations_to_roles_table.php b/database/migrations/2026_11_04_000000_add_translations_to_roles_table.php new file mode 100644 index 0000000..c832feb --- /dev/null +++ b/database/migrations/2026_11_04_000000_add_translations_to_roles_table.php @@ -0,0 +1,39 @@ +json('translations')->nullable(); + $table->string('lang')->nullable()->index("{$table_name}_lang_index"); + }); + } + + public function down(): void + { + $table_name = config('permission.table_names.roles', 'roles'); + + if (! Schema::hasColumn($table_name, 'translations')) { + return; + } + + Schema::table($table_name, function (Blueprint $table) use ($table_name) { + $table->dropIndex("{$table_name}_lang_index"); + $table->dropColumn(['translations', 'lang']); + }); + } +}; diff --git a/database/seeders/PermissionsSeeder.php b/database/seeders/PermissionsSeeder.php new file mode 100644 index 0000000..ab648e5 --- /dev/null +++ b/database/seeders/PermissionsSeeder.php @@ -0,0 +1,17 @@ +sync(); + } +} diff --git a/docs/advanced-usage/_category_.json b/docs/advanced-usage/_category_.json new file mode 100644 index 0000000..ca94e25 --- /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": "/permissions/_categories/advanced-usage", + "description": "Packages, multiple guards, overrides and every option." + } +} diff --git a/docs/advanced-usage/activity-log.md b/docs/advanced-usage/activity-log.md new file mode 100644 index 0000000..f0da7c7 --- /dev/null +++ b/docs/advanced-usage/activity-log.md @@ -0,0 +1,82 @@ +--- +title: Activity log +sidebar_position: 5 +--- + +When [spatie/laravel-activitylog](https://spatie.be/docs/laravel-activitylog) v4 or v5 is installed, the package logs role changes: + +| Description | Subject | Changes | +| --- | --- | --- | +| `role_updated` | The user whose roles changed (`updateRole()`) | `attributes.role`, `old.role` | +| `permissions_updated` | The role whose permissions changed (`syncPermissionIds()`, the role form) | `attributes.permissions`, `old.permissions` | + +The causer is the person who made the change. The logs have no `event`; tell them apart by description. + +Where the changes go depends on the version: + +- **v5** keeps model changes in the `attribute_changes` column, so the package writes them there (`withChanges()`), and `properties` stays empty. Diffs and descriptions treat them like any other model change. +- **v4** has no such column, so they go in `properties`, the way v4 logs its own model changes. `$activity->changes()` reads them. + +[javaabu/activitylog](https://github.com/Javaabu/activitylog) v2 is built on v5 and installs it. It also gives `role_updated` and `permissions_updated` their own tags and readable descriptions. + +```bash +composer require spatie/laravel-activitylog:"^5.0" +php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations" +php artisan migrate +``` + +There's nothing else to set up: the package checks for `activity()` and adds its listeners when it finds it. To turn logging off: + +```php +// config/permissions.php +'log_activity' => false, +``` + +Without the package, nothing is logged, but the `UserRoleUpdated` and `RolePermissionsUpdated` events still fire, so you can listen to them yourself. + +# Logging changes to the role itself + +v2's `Role` model logged its own attribute changes through javaabu/activitylog. v3's doesn't, so the package doesn't depend on an activity log. To log them, extend the model. With spatie/laravel-activitylog v5: + +```php +namespace App\Models; + +use Javaabu\Permissions\Models\Role as BaseRole; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; + +class Role extends BaseRole +{ + use LogsActivity; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults()->logAll(); + } +} +``` + +On v4 the imports are `Spatie\Activitylog\LogOptions` and `Spatie\Activitylog\Traits\LogsActivity`. + +With javaabu/activitylog v2, use its trait instead. Its defaults already leave out hidden attributes and timestamps, so you don't need `getActivitylogOptions()`: + +```php +namespace App\Models; + +use Javaabu\Activitylog\Traits\LogsActivity; +use Javaabu\Permissions\Models\Role as BaseRole; + +class Role extends BaseRole +{ + use LogsActivity; +} +``` + +Then point spatie/laravel-permission at your model: + +```php +// config/permission.php +'models' => [ + 'role' => App\Models\Role::class, +], +``` diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md new file mode 100644 index 0000000..afdda1b --- /dev/null +++ b/docs/advanced-usage/api-reference.md @@ -0,0 +1,120 @@ +--- +title: API reference +sidebar_position: 7 +--- + +# `Javaabu\Permissions\Permissions` + +A facade over the `PermissionRegistry` singleton, plus a few static helpers. + +| Method | Returns | What | +| --- | --- | --- | +| `register(string $group, array $permissions = [], ?string $label = null)` | `PermissionGroup` | Add `name => description` pairs (or names) to a group | +| `resource(string $group, ?string $noun = null, array $actions = ['view', 'edit', 'delete', 'force_delete'], ?string $label = null)` | `PermissionGroup` | Register `{action}_{group}` permissions | +| `role(string $name, ?string $description = null, string\|array $permissions = [], array\|string\|null $guards = null)` | `RoleDefinition` | Define a role the sync creates | +| `policy(string $model, string $policy)` | `PermissionRegistry` | Register a default policy | +| `sync(?array $guards = null, bool $prune = false)` | `SyncResult` | Write permissions and roles to the database | +| `group(string $name)` | `?PermissionGroup` | A registered group | +| `groups()` | `array` | Every group, in registration order | +| `permissions(?string $guard = null)` | `array` | Registered `name => description`, optionally for one guard | +| `has(string $permission)` | `bool` | Whether a permission is registered | +| `description(string $permission)` | `?string` | Its English description | +| `groupOf(string $permission)` | `?PermissionGroup` | The group it's in | +| `label(string $group)` | `string` | The untranslated label of a group | +| `roles()` | `array` | Roles from code, overridden by config | +| `policies()` | `array` | Package policies, overridden by config | +| `applyPolicies(?Gate $gate = null)` | `void` | Add the policies to the gate (done for you after boot) | +| `defaultGuards()` | `array` | `permissions.guards`, or the default guard | +| `guardsFor(PermissionGroup\|RoleDefinition $definition)` | `array` | The guards a group or role uses | +| `allGuards()` | `array` | Every guard any group or role uses | +| `guardFor(Model $user)` | `string` | The guard spatie uses for a user model | +| `check(string\|array $permissions, ?string $guard = null)` | `bool` | Whether the user signed in on the guard has any of them | +| `permissionName(string $action, string $resource)` | `string` | `edit` + `exports` = `edit_exports` | +| `ignoreMigrations()` | `void` | Don't load the package's migrations | +| `flush()` | `void` | Forget everything registered (for tests) | + +# `PermissionGroup` + +| Method | What | +| --- | --- | +| `add(array $permissions)` | Add permissions | +| `label(string $label)` | Set the label | +| `guards(array\|string $guards)` | Limit the group to these guards | +| `grantTo(array\|string $roles, ?array $only = null)` | Give existing roles the group's permissions on sync | +| `getPermissions()`, `getLabel()`, `getGuards()`, `getGrants()`, `has(string $permission)` | Read it back | +| `$name` | The group name | + +# `RoleDefinition` + +`$name`, `$description`, `$permissions`, `$guards`, `getDescription()`, `grantsEverything()`, `resolvePermissions(array $available)`. + +# `Javaabu\Permissions\Policies\ResourcePolicy` + +| Member | What | +| --- | --- | +| `protected string $resource` | The resource in permission names | +| `protected ?string $guard = null` | Check this guard's permissions | +| `protected bool $owned = false` | Use `edit_others_x` / `delete_others_x` for models the user doesn't own | +| `protected array $abilities = []` | Custom `ability => permission(s)` | +| `viewAny`, `view`, `create`, `update`, `delete`, `restore`, `forceDelete`, `viewTrash` | The standard abilities | +| `permission(string $action): string` | The permission name for an action | +| `protected allows(Model $user, string ...$permissions): bool` | Any of the permissions | +| `protected isOwner(Model $user, Model $model): bool` | Calls `$model->isOwnedBy($user)` | + +# `Javaabu\Permissions\Traits\HasRoles` + +Includes `Spatie\Permission\Traits\HasRoles`. + +| Method | What | +| --- | --- | +| `anyPermission(string\|array $permissions, ?string $guard = null): bool` | Any of the permissions | +| `updateRole(string\|array $role, bool $new = false, ?Model $causer = null, ?string $default_role = null): void` | Change roles if the causer may | +| `$model->role` | The first role | +| `whereRoleHasPermissions(string\|array $permissions)` | Scope: models whose roles have all of them | + +# `Javaabu\Permissions\Models\Role` + +Extends spatie's `Role`. Scopes `forGuard(string $guard)` and `search(?string $search)`; `translated_description`; `syncPermissionIds(array $permission_ids, ?Model $causer = null)`; `Role::factory()`. + +# `Javaabu\Permissions\Models\Permission` + +Extends spatie's `Permission`. Scopes `forGuard(string $guard)` and `inGroup(string $group)`; `translated_description`; `group_label`; `Permission::factory()`. The `model` column is the group. + +# Routing and HTTP + +| Class | What | +| --- | --- | +| `Route::roles(array $options = [])` | Registers the role routes | +| `Javaabu\Permissions\Routing\RoleRoutes` | `register()`, `guard(Request)`, `namePrefix(Request)`, `isRoleRoute(Request)` | +| `Javaabu\Permissions\Http\Controllers\RolesController` | The role controller | +| `Javaabu\Permissions\Http\Requests\RoleRequest` | Its validation; `role()` is the role being updated | +| `Javaabu\Permissions\Http\Resources\RoleResource`, `PermissionResource` | JSON shapes; roles include `can`, `links` (on role routes), `permissions_count` (when counted) and `translations` (when translated) | +| `Javaabu\Permissions\Support\Translations` | Optional translatable support: `enabled()`, `supports()`, `supportsRole()`, `rules()`, `values()`, `form()`. See [Translatable role descriptions](translatable-roles.md) | +| `Javaabu\Permissions\Models\Translatable\Role` | A role with a translatable `description` | +| `Javaabu\Permissions\Support\PermissionGroups` | `forGuard(string $guard, ?Model $role = null)`, `forRole(Model $role)` | +| `Javaabu\Permissions\Views\ViewRenderer` | `render(string $page, array $data)`, `stack()`, `isInertia()`, `inertiaPage(string $page)` | + +# Front end + +| Asset | What | +| --- | --- | +| `permissions::roles` | Script of the Blade pages: `data-permission-group-select` buttons and `data-confirm` forms | +| `permissions::Roles/Index`, `Create`, `Edit`, `Show` | The Inertia pages | +| `@javaabu/permissions` | `RoleForm`, `PermissionGroupFields`, `RoleActions` and the types (`RoleData`, `PermissionGroup`, `RolesStrings`, …) | + +# Console and seeders + +| | What | +| --- | --- | +| `php artisan permissions:sync {--guard=*} {--prune}` | Sync permissions and roles | +| `Javaabu\Permissions\Database\Seeders\PermissionsSeeder` | The same, as a seeder | + +# Blade + +| Directive | What | +| --- | --- | +| `@anypermission($permissions, $guard = null)` … `@endanypermission` | The signed-in user has any of the permissions | + +# Events + +`Javaabu\Permissions\Events\UserRoleUpdated` and `RolePermissionsUpdated`. See [Roles](../basic-usage/roles.md#events). diff --git a/docs/advanced-usage/configuration.md b/docs/advanced-usage/configuration.md new file mode 100644 index 0000000..4983c2d --- /dev/null +++ b/docs/advanced-usage/configuration.md @@ -0,0 +1,40 @@ +--- +title: Configuration +sidebar_position: 3 +--- + +`config/permissions.php` holds this package's options. Publish it with: + +```bash +php artisan vendor:publish --tag=permissions-config +``` + +| Key | Default | What | +| --- | --- | --- | +| `guards` | `[]` | Guards that registered permissions and configured roles are created for. When empty, `auth.defaults.guard` is used. See [Multiple guards](multi-auth.md). | +| `roles` | `super_admin` with `*` | Roles the sync creates: `name => ['description' => …, 'permissions' => '*' or [names and patterns], 'guards' => [...]]`. See [Roles](../basic-usage/roles.md). | +| `default_role` | `null` | The role `updateRole()` gives a new user when the causer can't choose one. | +| `policies` | `[]` | `Model::class => Policy::class` overrides for package policies. See [Policies](../basic-usage/policies.md). | +| `log_activity` | `true` | Log role and permission changes, when spatie/laravel-activitylog is installed. See [Activity log](activity-log.md). | +| `register_defaults` | `true` | Register the package's own `view_roles`, `edit_roles` and `delete_roles` permissions and its role policy. | +| `views` | `env('PERMISSIONS_VIEWS', 'blade')` | Which stack the role pages render with: `blade` or `inertia`. See [Managing roles](../basic-usage/managing-roles.md#pages). | +| `layout` | `'layouts.app'` | The layout the Blade pages `@extends`. | +| `layout_section` | `'content'` | The section of the layout the Blade pages fill. | +| `controllers.roles` | `RolesController::class` | The controller `Route::roles()` uses. | + +# spatie/laravel-permission's options + +`config/permission.php` holds spatie's options. The package merges in its own copy, which sets: + +```php +'models' => [ + 'permission' => Javaabu\Permissions\Models\Permission::class, + 'role' => Javaabu\Permissions\Models\Role::class, +], +``` + +To translate role descriptions, set `role` to `Javaabu\Permissions\Models\Translatable\Role` (see [Translatable role descriptions](translatable-roles.md)). If you use your own models, extend these, since the sync, controller and pages rely on their scopes and attributes. Spatie's options, such as `teams`, `cache` and `enable_wildcard_permission`, are documented in [spatie's docs](https://spatie.be/docs/laravel-permission). Publish them with: + +```bash +php artisan vendor:publish --tag=permissions-spatie-config +``` diff --git a/docs/advanced-usage/for-package-authors.md b/docs/advanced-usage/for-package-authors.md new file mode 100644 index 0000000..f533282 --- /dev/null +++ b/docs/advanced-usage/for-package-authors.md @@ -0,0 +1,136 @@ +--- +title: For package authors +sidebar_position: 1 +--- + +Every Javaabu package with controllers ships its permissions and policies through this package, so that apps manage them in one place and can override them. Here is a complete example for a package with an `Export` model. + +# composer.json + +```json +{ + "require": { + "javaabu/permissions": "^3.0" + } +} +``` + +# The service provider + +```php +namespace Javaabu\Exports; + +use Illuminate\Support\ServiceProvider; +use Javaabu\Exports\Models\Export; +use Javaabu\Exports\Policies\ExportPolicy; +use Javaabu\Permissions\Permissions; + +class ExportsServiceProvider extends ServiceProvider +{ + public function boot(): void + { + $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); + + Permissions::resource('exports', 'exports', label: 'Exports') + ->add(['download_exports' => 'Download exports']); + + Permissions::policy(Export::class, ExportPolicy::class); + } +} +``` + +# The policy + +```php +namespace Javaabu\Exports\Policies; + +use Javaabu\Permissions\Policies\ResourcePolicy; + +class ExportPolicy extends ResourcePolicy +{ + protected string $resource = 'exports'; + + protected array $abilities = [ + 'download' => 'download_exports', + ]; +} +``` + +# Translations + +```json +// lang/dv.json +{ + "Exports": "އެކްސްޕޯޓްތައް", + "View exports": "އެކްސްޕޯޓްތައް ބެލުން", + "Edit exports": "އެކްސްޕޯޓްތައް ބަދަލުކުރުން", + "Delete exports": "އެކްސްޕޯޓްތައް ފުހެލުން", + "Permanently delete exports": "އެކްސްޕޯޓްތައް ދާއިމީކޮށް ފުހެލުން", + "Download exports": "އެކްސްޕޯޓްތައް ޑައުންލޯޑުކުރުން" +} +``` + +# The controller + +Authorize with the policy as usual. Don't check a guard name or `App\Models\User`: + +```php +public function index(Request $request) +{ + $this->authorize('viewAny', Export::class); + + // … +} + +public function download(Request $request, Export $export) +{ + $this->authorize('download', $export); + + // … +} +``` + +# Tests + +Test with the permissions synced, and with two user models on two guards: + +```php +protected function setUp(): void +{ + parent::setUp(); + + config()->set('permissions.guards', ['web', 'web_admin']); + + $this->artisan('permissions:sync'); +} + +#[Test] +public function it_needs_the_download_permission(): void +{ + $admin = Admin::factory()->create(); + + $this->actingAs($admin, 'web_admin') + ->get(route('exports.download', $export)) + ->assertForbidden(); + + $admin->givePermissionTo('download_exports'); + + $this->actingAs($admin, 'web_admin') + ->get(route('exports.download', $export)) + ->assertOk(); +} +``` + +# What to document in your package + +- The permissions it registers and what each one allows. +- That apps run `php artisan permissions:sync` after installing or updating it. +- How to override its policy: `config('permissions.policies')` or `Gate::policy()`. + +# Rules + +- Register in `boot()`, not `register()`. +- Use `Permissions::policy()`, never `Gate::policy()`, for your default policies, or the app can't override them. +- Don't assume roles exist. Only use `grantTo()` for roles the app is documented to define. +- Don't create permissions with `Permission::create()` in migrations or seeders. Register them and let the sync create them. +- Don't hard-code guards. Leave them to `config('permissions.guards')` unless the permission only makes sense for one guard. diff --git a/docs/advanced-usage/multi-auth.md b/docs/advanced-usage/multi-auth.md new file mode 100644 index 0000000..1973748 --- /dev/null +++ b/docs/advanced-usage/multi-auth.md @@ -0,0 +1,84 @@ +--- +title: Multiple guards +sidebar_position: 2 +--- + +spatie/laravel-permission keeps permissions and roles per guard: `view_exports` on `web` and `view_exports` on `web_admin` are two different permissions. This package keeps that model, and adds config and helpers so that apps with several user models stay manageable. + +# Choosing the guards + +```php +// config/auth.php +'guards' => [ + 'web' => ['driver' => 'session', 'provider' => 'users'], + 'web_admin' => ['driver' => 'session', 'provider' => 'admins'], +], + +// config/permissions.php +'guards' => ['web_admin'], +``` + +Registered permissions and configured roles are created for each guard in `permissions.guards`, or for the default guard (`auth.defaults.guard`) when it's empty. A group or role can name its own: + +```php +Permissions::register('profile', ['edit_own_profile' => 'Edit own profile'])->guards('web'); +Permissions::role('member', 'Member', ['edit_own_profile'], guards: 'web'); +``` + +```php +Permissions::defaultGuards(); // ['web_admin'] +Permissions::guardsFor(Permissions::group('profile')); // ['web'] +Permissions::allGuards(); // ['web_admin', 'web'] +``` + +# Which guard a user model uses + +spatie works out the guard from the auth config: the guards whose provider uses the model's class. It prefers the default guard if that's one of them. + +```php +Permissions::guardFor($admin); // "web_admin" +``` + +If one model is used by several guards, set the one to use for permissions on the model: + +```php +protected string $guard_name = 'web_admin'; +``` + +A user can only get roles and permissions of its own guard. `updateRole()` ignores roles of other guards. + +# Checking against a guard + +```php +$admin->anyPermission(['view_reports'], 'web_admin'); +Permissions::check(['view_reports'], 'web_admin'); // the user signed in on web_admin +``` + +```blade +@anypermission(['view_reports'], 'web_admin') + … +@endanypermission +``` + +In a policy, set `protected ?string $guard = 'web_admin';` on a [`ResourcePolicy`](../basic-usage/policies.md#checking-one-guard). + +# Routes per guard + +```php +Route::roles(['guard' => 'web_admin', 'prefix' => 'admin/roles', 'name' => 'admin.roles']); +Route::roles(['guard' => 'web', 'prefix' => 'account/roles', 'name' => 'account.roles']); +``` + +Each set of routes adds `auth:{guard}` middleware and only sees roles and permissions of its guard. + +# Querying by guard + +```php +Role::query()->forGuard('web_admin')->get(); +Permission::query()->forGuard('web_admin')->inGroup('exports')->get(); +Permissions::permissions('web_admin'); // registered permissions on that guard +``` + +# Relating models to users + +Don't add a `user_id` column that assumes `App\Models\User`. Use a morph (`$table->nullableMorphs('owner')`), so admins and users can both own records. See [owned resources](../basic-usage/policies.md#owned-resources). diff --git a/docs/advanced-usage/overriding-controllers-and-views.md b/docs/advanced-usage/overriding-controllers-and-views.md new file mode 100644 index 0000000..7d7d6c0 --- /dev/null +++ b/docs/advanced-usage/overriding-controllers-and-views.md @@ -0,0 +1,91 @@ +--- +title: Overriding controllers and views +sidebar_position: 4 +--- + +# Controllers + +Extend `RolesController` and point the package at your class, either from config: + +```php +// config/permissions.php +'controllers' => [ + 'roles' => App\Http\Controllers\Admin\RolesController::class, +], +``` + +or for one set of routes: + +```php +Route::roles(['guard' => 'web_admin', 'controller' => App\Http\Controllers\Admin\RolesController::class]); +``` + +or by binding it in the container, which also affects routes registered before your provider boots: + +```php +$this->app->bind( + \Javaabu\Permissions\Http\Controllers\RolesController::class, + \App\Http\Controllers\Admin\RolesController::class, +); +``` + +A subclass can override any action, or one of these helpers: + +| Method | What | +| --- | --- | +| `query(Request $request): Builder` | The roles the routes can see; scoped to the guard | +| `findRole(Request $request, string $role): Model` | Finds a role or 404s | +| `form(Request $request, ?Model $role)` | Renders the create and edit pages | +| `attributes(RoleRequest $request): array` | The validated attributes saved on the role | +| `meta(Request $request, ?Model $role = null): array` | The props every page gets: `guard`, `routes`, `urls`, `can`, `strings` | +| `strings(): array` | The translated text of the Inertia pages | +| `routeNames(Request $request): array` | The route names passed to pages | + +```php +namespace App\Http\Controllers\Admin; + +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Request; +use Javaabu\Permissions\Http\Controllers\RolesController as BaseRolesController; + +class RolesController extends BaseRolesController +{ + protected function query(Request $request): Builder + { + return parent::query($request)->where('name', '!=', 'super_admin'); + } +} +``` + +`RoleRoutes::guard($request)` returns the guard of the current route. + +# Choosing the stack + +```php +// config/permissions.php +'views' => 'inertia', // or 'blade' +``` + +Requests that expect JSON always get JSON. Setting up each stack is described in [Managing roles](../basic-usage/managing-roles.md#pages). + +# Blade views + +Publish the views and edit the copies: + +```bash +php artisan vendor:publish --tag=permissions-views +``` + +Laravel uses `resources/views/vendor/permissions/roles/index.blade.php` in place of the package's. Override one view and leave the rest: `roles.index`, `roles.create`, `roles.edit`, `roles.show`, and the partials `roles.partials.form`, `roles.partials.row`, `roles.partials.actions`, `roles.partials.alerts` and `roles.partials.translatable-description`. Keep the `data-permission-group` and `data-permission-group-select` attributes for select all and none, and `data-confirm` on forms that should ask first. The [page props](../basic-usage/managing-roles.md#page-props) list what each view gets. + +# Inertia pages + +Copy a page into your app and edit it: + +```bash +php artisan manifest:override permissions js/Pages/Roles/Edit.tsx +``` + +It goes to `resources/vendor/permissions/js/Pages/Roles/Edit.tsx`, and `resolvePage()` uses it instead of the package's. Components work the same way (`js/components/RoleForm.tsx`), and the pages pick up your copy. + +To give the pages more props, override `meta()` or an action in your controller. diff --git a/docs/advanced-usage/translatable-roles.md b/docs/advanced-usage/translatable-roles.md new file mode 100644 index 0000000..26dde48 --- /dev/null +++ b/docs/advanced-usage/translatable-roles.md @@ -0,0 +1,88 @@ +--- +title: Translatable role descriptions +sidebar_position: 6.5 +--- + +With [javaabu/translatable](https://github.com/Javaabu/translatable) v3 installed, a role's description can be kept in every language of the app ("Editors" / "ލިޔުންތައް ބަދަލުކުރާ މީހުން"). Without it, nothing changes: a role has one description. + +Permission descriptions and group labels aren't stored per role; they're translation keys. Translate those in `lang/dv.json` (see [Dhivehi and translations](translations.md)). + +# Setting it up + +1. Install and set up javaabu/translatable (its languages table and at least one active language). + + ```bash + composer require javaabu/translatable:^3.0@dev + ``` + +2. Run the migrations. The package's `add_translations_to_roles_table` migration adds two nullable columns, `translations` (JSON) and `lang`, to the roles table. It runs whether or not translatable is installed. + + ```bash + php artisan migrate + ``` + +3. Use the translatable role model: + + ```php + // config/permission.php + 'models' => [ + 'permission' => Javaabu\Permissions\Models\Permission::class, + 'role' => Javaabu\Permissions\Models\Translatable\Role::class, + ], + ``` + + Or extend it with your own model. `Translatable\Role` extends `Javaabu\Permissions\Models\Role`, keeps its morph class (`getMorphClass()` returns the base role's), and has the same policy. + +Roles saved before this read as the default locale (`translatable.default_locale`). + +# What changes + +| Where | Without translatable | With translatable | +| --- | --- | --- | +| Form fields | `description` | `description_en`, `description_dv`, … one per active language, the default first | +| Validation | `description` required when creating | The role's primary language required when creating, the others optional | +| Blade form | `` | ``: a tab per language; Dhivehi with `dir="rtl"`, `lang="dv"` and the Thaana keyboard | +| React form | One `Text` | One `Text` per input in the `translations` prop, with its `dir`, `lang` and `thaana` | +| `RoleResource` | | `translations`: `{ "description_en": "Editors", "description_dv": "ލިޔުންތައް ބަދަލުކުރާ މީހުން" }` | +| `$role->description`, `translated_description` | The stored description | The description in the current locale, falling back to the primary one | + +```bash +curl -X POST -H 'Accept: application/json' https://example.test/admin/roles \ + -d name=editor -d description_en=Editors -d 'description_dv=ލިޔުންތައް ބަދަލުކުރާ މީހުން' +``` + +```php +$role = Role::findByName('editor', 'web_admin'); + +$role->translate('description', 'dv'); // "ލިޔުންތައް ބަދަލުކުރާ މީހުން" +Javaabu\Translatable\Translatable::values($role); // ['description_en' => 'Editors', 'description_dv' => '…'] +``` + +The create and edit pages (and their JSON) get `translations`: + +```json +{ + "values": { "description_en": "Editors", "description_dv": "ލިޔުންތައް ބަދަލުކުރާ މީހުން" }, + "inputs": { + "description": [ + { "field": "description", "name": "description_en", "locale": "en", "direction": "ltr", "thaana": false, "primary": true, "label": "Description (English)", "language": { "…": "…" } }, + { "field": "description", "name": "description_dv", "locale": "dv", "direction": "rtl", "thaana": true, "primary": false, "label": "Description (ދިވެހި)", "language": { "…": "…" } } + ] + } +} +``` + +# Checking support in your code + +`Javaabu\Permissions\Support\Translations` is safe to call without translatable installed: + +| Method | Returns | +| --- | --- | +| `enabled()` | Whether translatable is installed, on, and has a language | +| `supports($model, ?string $field = null)` | Whether the model (instance or class) is translatable | +| `supportsRole(?Model $role = null)` | Whether role descriptions are translated (the configured role class for a new role) | +| `rules(?Model $role, array $rules)` | The rules with `description` expanded per language when supported | +| `values(?Model $role)` | `['description_en' => …]`, or `[]` | +| `form(?Model $role)` | The `translations` prop above, or `null` | + +Searching the role list matches the stored description (the primary language) and the name, not the other languages. diff --git a/docs/advanced-usage/translations.md b/docs/advanced-usage/translations.md new file mode 100644 index 0000000..f37f8a4 --- /dev/null +++ b/docs/advanced-usage/translations.md @@ -0,0 +1,63 @@ +--- +title: Dhivehi and translations +sidebar_position: 6 +--- + +Every string the package shows goes through `__()`, and the package ships Dhivehi in `lang/dv.json`. With the app's locale set to `dv`, role pages and messages are in Dhivehi: + +```php +app()->setLocale('dv'); + +__('Role created'); // "ރޯލް އުފެއްދިއްޖެ" +``` + +# Permission descriptions and labels + +A permission's description and its group's label are English strings used as translation keys. Translate them in a JSON file: + +```json +// lang/dv.json +{ + "Exports": "އެކްސްޕޯޓްތައް", + "View exports": "އެކްސްޕޯޓްތައް ބެލުން" +} +``` + +```php +$permission->translated_description; // "އެކްސްޕޯޓްތައް ބެލުން" +$permission->group_label; // "އެކްސްޕޯޓްތައް" +$role->translated_description; +``` + +Packages ship their lines with `loadJsonTranslationsFrom()`. The app's own `lang/dv.json` is loaded after every package's, so any line can be overridden there. + +Role descriptions are free text: they can be written in Thaana directly ("ލިޔުންތައް ބަދަލުކުރާ މީހުން"), and the description field uses `dir="auto"` so either language reads the right way. To keep a description in several languages, use [translatable role descriptions](translatable-roles.md): the Dhivehi input then gets `dir="rtl"`, `lang="dv"` and the Thaana keyboard. Role names are identifiers, so they must be ASCII (`editor`) and are shown left to right. + +# Right to left + +The Blade and React pages use logical utilities only (`ms-`, `pe-`, `text-start`, `gap-`), so they flip when the page is `dir="rtl"`. A test fails if a Blade view uses `ml-`, `text-left` and the like. Set the direction in your layout: + +```blade + +``` + +The React pages get their text in the `strings` prop, translated on the server, so there's nothing to load in the browser. + +# Translations to review + +These Dhivehi lines are best guesses and should be checked by a native speaker: + +| English | Dhivehi | Note | +| --- | --- | --- | +| Super Admin | ސުޕަރ އެޑްމިން | Transliterated | +| Roles | ރޯލްތައް | Transliterated; "ދައުރުތައް" is an alternative | +| Delete roles | ރޯލްތައް ފުހެލުން | "ފުހެލުން" for delete, as in javaabu/forms | +| Role created / updated / deleted | ރޯލް އުފެއްދިއްޖެ / ބަދަލުކުރެވިއްޖެ / ފުހެލެވިއްޖެ | Tone of the flash messages | +| Details | މަޢުލޫމާތު | Card title on the role pages | +| Guard | ގާޑު | Transliterated; an auth guard such as `web_admin` | +| Letters, numbers, dashes and underscores, e.g. content_editor | އިނގިރޭސި އަކުރު، ނަންބަރު، ޑޭޝް އަދި އަންޑަސްކޯ، މިސާލަކަށް content_editor | Help text of the name field | +| Select none | އެއްވެސް އެއްޗެއް ޚިޔާރު ނުކުރާ | "Select all" is javaabu/forms' ހުރިހާ އެއްޗެއް ޚިޔާރުކުރޭ | +| Delete this role? Users with it lose its permissions. | މި ރޯލް ފުހެލަންވީތޯ؟ މި ރޯލް ލިބިފައިވާ ފަރާތްތަކަށް އޭގެ ހުއްދަތައް ގެއްލޭނެ. | Confirmation | +| No roles match your search. | ހޯދި ގޮތާ ގުޅޭ އެއްވެސް ރޯލެއް ނުފެނުނު. | Empty search | +| :count permissions | :count ހުއްދަ | No plural form in Dhivehi | +| Save / Edit / View | ސޭވްކުރޭ / ބަދަލުކުރޭ / ބަލާ | Button verbs; javaabu/forms and exports use the same forms | diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md new file mode 100644 index 0000000..1906bfa --- /dev/null +++ b/docs/ai-assistants.md @@ -0,0 +1,34 @@ +--- +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/permissions/llms.txt` | The rules for using the package, and every page | +| `vendor/javaabu/permissions/docs/` | The pages | +| `vendor/javaabu/permissions/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/permissions` 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 +## Permissions + +Roles, permissions and policies use javaabu/permissions. Before adding a permission, policy or role, read vendor/javaabu/permissions/llms.txt. +``` + +Or copy `vendor/javaabu/permissions/resources/boost/skills/javaabu-permissions` into your assistant's skills folder, such as `.claude/skills/`. diff --git a/docs/basic-usage/checking-permissions.md b/docs/basic-usage/checking-permissions.md new file mode 100644 index 0000000..04dcf68 --- /dev/null +++ b/docs/basic-usage/checking-permissions.md @@ -0,0 +1,51 @@ +--- +title: Checking permissions +sidebar_position: 5 +--- + +Permissions are registered on the gate by spatie/laravel-permission, so everything Laravel offers works: + +```php +$user->can('view_exports'); +Gate::allows('view_exports'); +$this->authorize('update', $export); +``` + +```blade +@can('view_exports') + {{ __('Exports') }} +@endcan +``` + +Spatie's middleware works too, as long as you register its aliases in `bootstrap/app.php`: + +```php +Route::get('/exports', ExportsController::class)->middleware('permission:view_exports,web_admin'); +``` + +# Any of several permissions + +```php +$user->anyPermission(['edit_exports', 'delete_exports']); // through the gate +$user->anyPermission(['edit_exports', 'delete_exports'], 'web_admin'); // only that guard's permissions +``` + +Through the gate, `Gate::before()` callbacks apply. With a guard, permissions that don't exist on that guard count as missing. + +```blade +@anypermission(['edit_exports', 'delete_exports']) + … +@endanypermission + +@anypermission(['view_reports'], 'web_admin') + … +@endanypermission +``` + +The directive checks the user signed in on the given guard, or on the default guard. The same check is available in PHP: + +```php +use Javaabu\Permissions\Permissions; + +Permissions::check(['view_reports'], 'web_admin'); // false for guests +``` diff --git a/docs/basic-usage/how-to-use-feature.md b/docs/basic-usage/how-to-use-feature.md deleted file mode 100644 index eb46af9..0000000 --- a/docs/basic-usage/how-to-use-feature.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: How to use some feature ---- - -Write the name of the feature as the title and give instructions on how to use it diff --git a/docs/basic-usage/managing-roles.md b/docs/basic-usage/managing-roles.md new file mode 100644 index 0000000..6c71382 --- /dev/null +++ b/docs/basic-usage/managing-roles.md @@ -0,0 +1,258 @@ +--- +title: Managing roles +sidebar_position: 6 +--- + +The package has routes, a controller and pages for listing, creating, editing and deleting roles and choosing their permissions. + +# Registering the routes + +Routes aren't loaded automatically. Register them where you want them, with the guard they manage roles for: + +```php +// routes/web.php +use Illuminate\Support\Facades\Route; + +Route::middleware('web')->prefix('admin')->name('admin.')->group(function () { + Route::roles(['guard' => 'web_admin']); +}); +``` + +| Method | URI | Name | Action | +| --- | --- | --- | --- | +| GET | `admin/roles` | `admin.roles.index` | `index` | +| GET | `admin/roles/create` | `admin.roles.create` | `create` | +| POST | `admin/roles` | `admin.roles.store` | `store` | +| GET | `admin/roles/{role}` | `admin.roles.show` | `show` | +| GET | `admin/roles/{role}/edit` | `admin.roles.edit` | `edit` | +| PUT/PATCH | `admin/roles/{role}` | `admin.roles.update` | `update` | +| DELETE | `admin/roles/{role}` | `admin.roles.destroy` | `destroy` | + +Options: + +| Option | Default | What | +| --- | --- | --- | +| `guard` | the first of `permissions.guards`, or the default guard | The guard whose roles the routes manage | +| `prefix` | `roles` | URI prefix | +| `name` | `roles` | Route name prefix, inside any group name | +| `middleware` | `['auth:{guard}']` | Middleware for the routes. Pass your own to replace it | +| `controller` | `config('permissions.controllers.roles')` | The controller class | + +Register the routes once per guard to manage roles of several guards: + +```php +Route::roles(['guard' => 'web', 'prefix' => 'account/roles', 'name' => 'account.roles']); +``` + +Each set of routes only sees roles of its own guard; a role of another guard is a 404. + +# Authorization + +Every action is authorized with the [role policy](policies.md#the-role-policy): + +| Action | Ability | Permission | +| --- | --- | --- | +| `index` | `viewAny` | `view_roles` | +| `show` | `view` | `view_roles` | +| `create`, `store` | `create` | `edit_roles` | +| `edit`, `update` | `update` | `edit_roles` | +| `destroy` | `delete` | `delete_roles` | + +# Validation + +`store` and `update` validate with `Javaabu\Permissions\Http\Requests\RoleRequest`: + +- `name`: required when creating, ASCII letters, numbers, dashes and underscores, and unique among roles of the guard. +- `description`: required when creating, up to 255 characters, any language. +- `permissions`: ids of permissions of the guard. + +On a form submission (not JSON), a missing `permissions` field means no permission is ticked, so the role loses all its permissions. A JSON update without `permissions` leaves them alone. + +# JSON API + +Every action answers JSON when the request expects it (`Accept: application/json`). That gives you an API for single-page apps and scripts: + +```bash +curl -H 'Accept: application/json' https://example.test/admin/roles?search=editor +``` + +```json +{ + "data": [ + { + "id": 2, + "name": "editor", + "description": "ލިޔުންތައް ބަދަލުކުރާ މީހުން", + "translated_description": "ލިޔުންތައް ބަދަލުކުރާ މީހުން", + "guard_name": "web_admin", + "permissions_count": 3, + "can": { "view": true, "update": true, "delete": false }, + "links": { + "show": "https://example.test/admin/roles/2", + "edit": "https://example.test/admin/roles/2/edit", + "update": "https://example.test/admin/roles/2", + "destroy": "https://example.test/admin/roles/2" + }, + "created_at": "2026-11-04T10:00:00+05:00", + "updated_at": "2026-11-04T10:00:00+05:00" + } + ], + "links": { "…": "…" }, + "meta": { "current_page": 1, "per_page": 20, "total": 1, "…": "…" } +} +``` + +| Request | Response | +| --- | --- | +| `GET /roles?search=&per_page=` | Paginated `RoleResource`s, up to 100 per page | +| `GET /roles/create` | `{ "data": { "role": null, "permission_groups": [...], "guard": "web_admin", "translations": null } }` | +| `POST /roles` | `201` with the role and its `permissions` | +| `GET /roles/{role}` | The role and its `permissions` | +| `GET /roles/{role}/edit` | `{ "data": { "role": {...}, "permission_groups": [...], "guard": "…", "translations": null } }` | +| `PUT/PATCH /roles/{role}` | The updated role | +| `DELETE /roles/{role}` | `204` | + +`permission_groups` is a list of groups, in the order they were registered: + +```json +{ + "name": "exports", + "label": "އެކްސްޕޯޓްތައް", + "permissions": [ + { "id": 7, "name": "view_exports", "description": "އެކްސްޕޯޓްތައް ބެލުން", "granted": true } + ] +} +``` + +Labels and descriptions are translated into the current locale. `can` says what the current user may do with each role, and `links` holds the URLs of its pages under the route names you registered. When descriptions are [translated](../advanced-usage/translatable-roles.md), roles also have `translations` (`{ "description_en": …, "description_dv": … }`) and the form data has the inputs for each language. + +# Pages + +Without JSON, the actions render pages with the stack in `config('permissions.views')`: Blade (the default) or Inertia React. Once saved, `store` and `update` redirect to the edit page and `destroy` to the index, each with a translated `success` flash message. + +| Page | Blade view | Inertia page | +| --- | --- | --- | +| List, with search | `permissions::roles.index` | `permissions::Roles/Index` | +| New | `permissions::roles.create` | `permissions::Roles/Create` | +| View, with the granted permissions | `permissions::roles.show` | `permissions::Roles/Show` | +| Edit | `permissions::roles.edit` | `permissions::Roles/Edit` | + +The form has the role's name and description, and a card of permission checkboxes for each group, with **Select all** and **Select none**. Buttons only show when the user may use them, and deleting asks first. + +## Blade + +The views are built with [javaabu/forms](https://github.com/Javaabu/forms) v2 components, so they follow your `forms.framework` (Onu or Bootstrap 6). They extend your layout: + +```php +// config/permissions.php +'views' => 'blade', +'layout' => 'layouts.app', // the view the pages @extends +'layout_section' => 'content', // the section they fill +``` + +```blade +{{-- resources/views/layouts/app.blade.php --}} + + +{{ $title ?? config('app.name') }} + + @yield('content') + + +``` + +A small script, `permissions::roles` (select all and none, and the delete confirmation), comes through [javaabu/manifest](https://github.com/Javaabu/manifest). The pages require it themselves; add manifest's Vite plugin to your build: + +```js +// vite.config.js +import { defineConfig } from "vite" +import laravel from "laravel-vite-plugin" +import manifest from "./vendor/javaabu/manifest/dist/vite.js" + +export default defineConfig({ + plugins: [ + laravel({ input: ["resources/css/app.css", "resources/js/app.js"] }), + manifest(), + ], +}) +``` + +The script imports no npm packages, so the Blade pages need nothing beyond what javaabu/forms' Blade components need. `php artisan manifest:check` doesn't ask a Blade app for React, `@inertiajs/react` or `@javaabu/forms`: permissions declares those for its React pages only, and the check skips them while the app's `package.json` has no Inertia adapter. + +The pages use Tailwind utilities from Onu's theme, logical ones only (`ms-`, `text-start`), so they work right to left. Let Tailwind see them: + +```css +/* resources/css/app.css */ +@source "../../vendor/javaabu/permissions/resources/views"; +``` + +## Inertia React + +```php +// config/permissions.php +'views' => 'inertia', +``` + +The pages are written in TypeScript with [@javaabu/forms](https://github.com/Javaabu/react-forms) and ship inside the Composer package; javaabu/manifest makes them available to your build. Resolve them with `resolvePage()`: + +```tsx +// resources/js/app.tsx +import { createInertiaApp } from "@inertiajs/react" +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() + }, +}) +``` + +Install what the pages need, then check: + +```bash +composer require inertiajs/inertia-laravel +php artisan manifest:check # prints the install command for anything missing +``` + +They need `@javaabu/forms`, `@javaabu/onu`, `react`, `react-dom` and `@inertiajs/react`, and so do the components you import from `@javaabu/permissions`. `manifest:check` lists them once your `package.json` has `@inertiajs/react`; before that, run `php artisan manifest:check --scope=pages`. Wrap your app in `FormsProvider` with the Onu and Inertia adapters, as @javaabu/forms describes, so forms submit as Inertia visits. Your persistent layout wraps the pages and shows the `success` flash message as it does for your own pages. + +The pages' text comes from the server, translated, in the `strings` prop, so they're in Dhivehi when the app's locale is `dv`. + +You can use the pages' parts in your own pages: + +```tsx +import { PermissionGroupFields, RoleActions, RoleForm } from "@javaabu/permissions" +import type { PermissionGroup, RoleData } from "@javaabu/permissions" + +export default function EditRole({ role, permission_groups, translations, strings, urls }) { + return ( + + ) +} +``` + +## Page props + +| Prop | Pages | What | +| --- | --- | --- | +| `title` | all | The translated page title | +| `guard` | all | The guard the routes manage | +| `routes` | all | Route names by action, e.g. `routes.edit` is `admin.roles.edit` | +| `urls` | all | `index`, `create` and `store`, plus `show`, `edit`, `update` and `destroy` on a role's pages | +| `can` | all | `{ create, update, delete }` for the current user; `update` and `delete` are about the page's role | +| `strings` | all (Inertia only) | The pages' translated text | +| `roles` | index | A paginator (Blade) or a paginated `RoleResource` collection (Inertia) | +| `search` | index | The search term, or null | +| `role` | create, show, edit | The role model (Blade) or a `RoleResource` array (Inertia); null on create | +| `permission_groups` | create, show, edit | As in the JSON above; on show, only the role's permissions | +| `translations` | create, edit | `{ values, inputs }` when descriptions are [translated](../advanced-usage/translatable-roles.md), otherwise null | + +To change the pages, see [Overriding controllers and views](../advanced-usage/overriding-controllers-and-views.md). diff --git a/docs/basic-usage/policies.md b/docs/basic-usage/policies.md new file mode 100644 index 0000000..ad444f7 --- /dev/null +++ b/docs/basic-usage/policies.md @@ -0,0 +1,170 @@ +--- +title: Policies +sidebar_position: 4 +--- + +# Registering a default policy + +A package registers the policy for its models through the package, not with `Gate::policy()`, so the app can replace it: + +```php +use Javaabu\Permissions\Permissions; + +public function boot(): void +{ + Permissions::policy(Export::class, ExportPolicy::class); +} +``` + +Policies are added to the gate once the app has booted. A package policy is only used when nothing else is set for that model, so the order is: + +1. `config('permissions.policies')`, always. +2. A policy the app set with `Gate::policy()`, in any service provider. +3. The package's default. + +Subclasses of the model use the same policy, as with `Gate::policy()`. + +# Overriding a policy in the app + +From config: + +```php +// config/permissions.php +'policies' => [ + \Javaabu\Exports\Models\Export::class => \App\Policies\ExportPolicy::class, +], +``` + +Or from a service provider, as usual: + +```php +use Illuminate\Support\Facades\Gate; + +public function boot(): void +{ + Gate::policy(\Javaabu\Exports\Models\Export::class, \App\Policies\ExportPolicy::class); +} +``` + +To change one ability, extend the package's policy: + +```php +namespace App\Policies; + +use Illuminate\Database\Eloquent\Model; +use Javaabu\Exports\Policies\ExportPolicy as BaseExportPolicy; + +class ExportPolicy extends BaseExportPolicy +{ + public function delete(Model $user, Model $model): bool + { + return parent::delete($user, $model) && $model->created_at->isToday(); + } +} +``` + +`Permissions::policies()` lists the registered policies with the config overrides applied. + +# ResourcePolicy + +`Javaabu\Permissions\Policies\ResourcePolicy` maps the standard abilities to the permissions [`Permissions::resource()`](registering-permissions.md#the-standard-permissions-of-a-resource) registers. Set the resource and you're done: + +```php +namespace Javaabu\Exports\Policies; + +use Javaabu\Permissions\Policies\ResourcePolicy; + +class ExportPolicy extends ResourcePolicy +{ + protected string $resource = 'exports'; +} +``` + +| Ability | Allowed with | +| --- | --- | +| `viewAny` | `view_exports` | +| `view` | `view_exports` | +| `create` | `edit_exports` | +| `update` | `edit_exports` | +| `delete` | `delete_exports` | +| `restore` | `delete_exports` or `force_delete_exports` | +| `forceDelete` | `force_delete_exports` | +| `viewTrash` | `delete_exports` or `force_delete_exports` | + +Guests are always denied. Every ability takes the user as a `Model`, so it works with any user model. + +## Custom abilities + +Map other abilities to permissions in `$abilities`. A list means the user needs any of them: + +```php +class ExportPolicy extends ResourcePolicy +{ + protected string $resource = 'exports'; + + protected array $abilities = [ + 'download' => 'download_exports', + 'review' => ['approve_exports', 'publish_exports'], + ]; +} +``` + +```php +$user->can('download', Export::class); +``` + +Abilities that aren't defined are denied. For anything more involved, write a method: + +```php +public function download(Model $user, Export $export): bool +{ + return $this->allows($user, 'download_exports') && $export->isReady(); +} +``` + +`$this->allows($user, ...$permissions)` is true when the user has any of the permissions. `$this->permission('edit')` returns `edit_exports`. + +## Owned resources + +With `$owned = true`, `update` and `delete` tell the user's own models apart from other people's: + +```php +class OauthClientPolicy extends ResourcePolicy +{ + protected string $resource = 'oauth_clients'; + + protected bool $owned = true; +} +``` + +| Ability | Allowed with | +| --- | --- | +| `update` | `edit_others_oauth_clients`, or `edit_oauth_clients` and owning it | +| `delete` | `delete_others_oauth_clients`, or `delete_oauth_clients` and owning it | +| `view` | `view_oauth_clients`, or being allowed to `update` it | + +Register the matching permissions with `Permissions::resource('oauth_clients', actions: ['view', 'edit', 'edit_others', 'delete', 'delete_others'])`. + +Ownership calls `$model->isOwnedBy($user)` if the model has it, and is false otherwise. Relate models to users through a morph, so that any user model can own them: + +```php +public function isOwnedBy(Model $user): bool +{ + return $this->owner_type === $user->getMorphClass() + && $this->owner_id == $user->getKey(); +} +``` + +Or override `isOwner(Model $user, Model $model): bool` in the policy. + +## Checking one guard + +By default, the policy checks permissions through the gate (`$user->can('view_exports')`), so `Gate::before()` callbacks apply. Set `$guard` to check permissions of one guard instead: + +```php +protected ?string $guard = 'web_admin'; +``` + +# The role policy + +The package registers `Javaabu\Permissions\Policies\RolePolicy` for the role model. It uses `view_roles`, `edit_roles` and `delete_roles`, and the package registers those permissions in the `roles` group. Turn both off with `'register_defaults' => false`, or override the policy like any other. diff --git a/docs/basic-usage/registering-permissions.md b/docs/basic-usage/registering-permissions.md new file mode 100644 index 0000000..cf62538 --- /dev/null +++ b/docs/basic-usage/registering-permissions.md @@ -0,0 +1,143 @@ +--- +title: Registering permissions +sidebar_position: 1 +--- + +Permissions are declared in code, in a service provider's `boot()` method, and written to the database by [`permissions:sync`](syncing-permissions.md). Registering doesn't touch the database, so it's cheap to do on every request. + +# Groups + +Every permission belongs to a group, usually the model or the package it's for. The group is stored in the `model` column of `permissions`, and the role form shows one section per group. + +```php +use Javaabu\Permissions\Permissions; + +public function boot(): void +{ + Permissions::register('exports', [ + 'view_exports' => 'View exports', + 'download_exports' => 'Download exports', + ], label: 'Exports'); +} +``` + +The key is the permission name and the value is its description, in English. Both the description and the label are [translation keys](#translating-descriptions-and-labels). + +Calling `register()` again with the same group adds to it. Without a `label`, the group's label is a headline of its name: `blog_posts` becomes "Blog Posts". + +If you pass a list of names instead of pairs, a description is made from each name: + +```php +Permissions::register('reports', ['view_reports', 'download_reports']); +// view_reports => "View reports" +``` + +# The standard permissions of a resource + +`resource()` registers the conventional permissions for a model: + +```php +Permissions::resource('exports'); +``` + +| Permission | Description | +| --- | --- | +| `view_exports` | View exports | +| `edit_exports` | Edit exports | +| `delete_exports` | Delete exports | +| `force_delete_exports` | Permanently delete exports | + +These match the abilities of [`ResourcePolicy`](policies.md). Choose other actions, set the noun used in the descriptions, and set a label: + +```php +Permissions::resource( + 'oauth_clients', + 'OAuth2 clients', + ['view', 'edit', 'edit_others', 'delete', 'delete_others'], + label: 'OAuth2 clients', +); +// view_oauth_clients => "View OAuth2 clients" +// edit_others_oauth_clients => "Edit all OAuth2 clients" +``` + +Actions with a built-in description: + +| Action | Description | +| --- | --- | +| `view` | View :resource | +| `edit` | Edit :resource | +| `edit_others` | Edit all :resource | +| `delete` | Delete :resource | +| `delete_others` | Delete all :resource | +| `force_delete` | Permanently delete :resource | +| `approve` | Approve :resource | +| `publish` | Publish :resource | +| `import` | Import :resource | +| `export` | Export :resource | + +Any other action gets a headline of its name: `archive` becomes "Archive :resource". The noun defaults to the group name with spaces, such as `blog posts` for `blog_posts`. + +`resource()` returns the group, so you can add more permissions to it: + +```php +Permissions::resource('exports') + ->add(['download_exports' => 'Download exports']); +``` + +# Guards + +Permissions are created for every guard in `config('permissions.guards')`, or for the app's default guard when that's empty. A group can name its own guards: + +```php +Permissions::register('reports', ['view_reports' => 'View reports']) + ->guards(['web_admin']); +``` + +See [Multiple guards](../advanced-usage/multi-auth.md). + +# Default grants + +`grantTo()` gives a role a group's permissions when you sync, if the role exists. Grants only ever add permissions; they never remove what an admin set on the role form. + +```php +Permissions::resource('exports') + ->grantTo('editor') // every permission in the group + ->grantTo(['auditor', 'viewer'], only: ['view_exports']); +``` + +The sync skips roles that don't exist and reports them. Packages can't know which roles an app has, so a package should only grant to roles the app defines in [config](roles.md), and say so in its docs. + +# Translating descriptions and labels + +Descriptions and labels are English strings that are also translation keys. Wherever they're shown, they go through `__()`: + +```php +$permission->translated_description; // __('View exports') +$permission->group_label; // __('Exports') +``` + +Ship the translations in a JSON file: + +```json +// lang/dv.json +{ + "Exports": "އެކްސްޕޯޓްތައް", + "View exports": "އެކްސްޕޯޓްތައް ބެލުން" +} +``` + +A package loads that file with `$this->loadJsonTranslationsFrom(__DIR__ . '/../lang')`. An app overrides any line in its own `lang/dv.json`. + +# Looking permissions up + +```php +Permissions::has('view_exports'); // true +Permissions::description('view_exports'); // "View exports" +Permissions::groupOf('view_exports')->name; // "exports" +Permissions::label('exports'); // "Exports" +Permissions::permissions(); // ['view_exports' => 'View exports', …] +Permissions::permissions('web_admin'); // only groups on that guard +Permissions::group('exports'); // PermissionGroup, or null +Permissions::groups(); // every PermissionGroup, by name +Permissions::permissionName('edit', 'exports'); // "edit_exports" +``` diff --git a/docs/basic-usage/roles.md b/docs/basic-usage/roles.md new file mode 100644 index 0000000..7b4b2a5 --- /dev/null +++ b/docs/basic-usage/roles.md @@ -0,0 +1,95 @@ +--- +title: Roles +sidebar_position: 3 +--- + +# Defining roles + +The sync creates the roles in `config/permissions.php`, for every guard in `permissions.guards`: + +```php +'roles' => [ + 'super_admin' => [ + 'description' => 'Super Admin', + 'permissions' => '*', + ], + 'editor' => [ + 'description' => 'Editor', + 'permissions' => ['view_*', 'edit_posts'], + ], + 'auditor' => [ + 'description' => 'Auditor', + 'permissions' => ['view_*'], + 'guards' => ['web_admin'], + ], +], +``` + +- `permissions` is either `'*'`, for every permission of the guard, kept in sync, or a list of names and [`Str::is()`](https://laravel.com/docs/strings#method-str-is) patterns. A list only ever adds permissions, so admins can grant more from the role form. +- `description` is an English string and a translation key. It defaults to a headline of the name. +- `guards` defaults to `permissions.guards`. + +`super_admin` with `'*'` is the default. Removing it from the config doesn't delete the role. + +You can also define roles in code. Config entries with the same name replace them: + +```php +Permissions::role('auditor', 'Auditor', ['view_*'], guards: 'web_admin'); +``` + +# Assigning roles + +Roles come from spatie/laravel-permission, so `assignRole()`, `syncRoles()`, `hasRole()` and the rest work as usual. A role belongs to a guard, and a user can only get roles of its own guard. + +```php +$user->assignRole('editor'); +$user->role; // the first role, or null +``` + +`updateRole()` changes the roles from a form, checking that the person making the change may: + +```php +$user->updateRole($request->input('role'), causer: $request->user()); +``` + +- The causer may change roles when it passes the `updateRole` ability for the user model's class. Define that ability in the model's policy. +- When it may not, nothing changes. The exception is a new user (`new: true`), who gets `config('permissions.default_role')`, or the `default_role` you pass. +- Roles that don't exist for the user's guard are ignored. If none of them exist, nothing changes. +- A causer that may change roles can pass `[]` to remove every role. +- The causer defaults to the signed-in user. +- When the roles change, it dispatches `UserRoleUpdated`. + +```php +$user->updateRole('admin', new: true, default_role: 'member'); +``` + +# Changing a role's permissions + +`syncPermissionIds()` replaces a role's permissions with the given ids. Ids of other guards are ignored. When the permissions change, it dispatches `RolePermissionsUpdated`: + +```php +$role->syncPermissionIds($request->input('permissions', []), causer: $request->user()); +``` + +# Querying + +```php +use Javaabu\Permissions\Models\Role; + +Role::query()->forGuard('web_admin')->get(); +Role::query()->search('editor')->get(); // name or description +$role->translated_description; // __($role->description) + +User::query()->whereRoleHasPermissions(['view_exports', 'edit_exports'])->get(); +``` + +`whereRoleHasPermissions()` finds models with a role that has all the given permissions. Spatie's `permission()` scope also counts direct permissions, and matches any of them. + +# Events + +| Event | When | Properties | +| --- | --- | --- | +| `Javaabu\Permissions\Events\UserRoleUpdated` | `updateRole()` changed a model's roles | `old_role`, `new_role` (name arrays), `subject`, `causer` | +| `Javaabu\Permissions\Events\RolePermissionsUpdated` | `syncPermissionIds()` changed a role's permissions | `old_permissions`, `new_permissions` (name arrays), `role`, `causer` | + +With spatie/laravel-activitylog installed, both events are [logged](../advanced-usage/activity-log.md). diff --git a/docs/basic-usage/syncing-permissions.md b/docs/basic-usage/syncing-permissions.md new file mode 100644 index 0000000..bd5c909 --- /dev/null +++ b/docs/basic-usage/syncing-permissions.md @@ -0,0 +1,69 @@ +--- +title: Syncing permissions +sidebar_position: 2 +--- + +`permissions:sync` writes the registered permissions and roles to the database: + +```bash +php artisan permissions:sync +``` + +For every guard, it: + +1. Creates each registered permission that's missing, and updates its description and group if they changed. +2. Creates the [roles](roles.md) from config and code, and updates their descriptions. +3. Gives each role its permissions. A `*` role is synced to every permission of its guard, so it loses permissions that no longer exist. Any other role only gets permissions added. +4. Applies the [default grants](registering-permissions.md#default-grants) of each group to roles that exist. +5. Clears spatie's permission cache. + +Running it twice changes nothing the second time, so put it in your deploy script after `php artisan migrate`. + +# Options + +```bash +php artisan permissions:sync --guard=web_admin # only this guard (repeatable) +php artisan permissions:sync --prune # also delete stale permissions +``` + +`--prune` deletes permissions whose group is registered but whose name isn't anymore, such as a permission a package removed. Permissions in groups that aren't registered, like ones an app created by hand, are never pruned. Deleting a permission removes it from every role and user. + +# From a seeder or code + +```php +use Javaabu\Permissions\Database\Seeders\PermissionsSeeder; + +class DatabaseSeeder extends Seeder +{ + public function run(): void + { + $this->call(PermissionsSeeder::class); + } +} +``` + +```php +use Javaabu\Permissions\Permissions; + +$result = Permissions::sync(guards: ['web_admin'], prune: true); + +$result->created_permissions; // ['web_admin:view_exports', …] +$result->updated_permissions; +$result->pruned_permissions; +$result->created_roles; +$result->updated_roles; +$result->skipped_grants; // grants to roles that don't exist +``` + +# In tests + +Sync in `setUp()` after `RefreshDatabase` has migrated: + +```php +protected function setUp(): void +{ + parent::setUp(); + + $this->artisan('permissions:sync'); +} +``` diff --git a/docs/changelog.md b/docs/changelog.md index 9ca4fd2..68c0421 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,4 +3,4 @@ title: Changelog sidebar_position: 1.5 --- -All notable changes to this package are documented on [GitHub](https://github.com/Javaabu/permissions/blob/main/CHANGELOG.md) +All notable changes to this package are documented on [GitHub](https://github.com/Javaabu/permissions/blob/main/CHANGELOG.md). Moving from v2? Read [Upgrading](upgrading.md). diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index dd5ca7e..d7fe09e 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -3,22 +3,85 @@ title: Installation & Setup sidebar_position: 1.2 --- -You can install the package via composer: +# Installing ```bash composer require javaabu/permissions ``` -# Publishing the config file +The service provider is discovered automatically, and so are those of spatie/laravel-permission, javaabu/forms and javaabu/manifest. Until javaabu/forms v2 and javaabu/manifest are on Packagist, add their repositories first (see [Requirements](requirements.md)). -Publishing the config file is optional: +# Migrating + +The package loads its migrations itself. They create spatie's tables, plus a `description` column on `roles` and `permissions`, a `model` column (the permission group) on `permissions`, and the nullable `translations` and `lang` columns on `roles` for [translatable descriptions](advanced-usage/translatable-roles.md). + +```bash +php artisan migrate +``` + +To change the migrations, publish them and stop the package from loading its own copy: + +```bash +php artisan vendor:publish --tag=permissions-migrations +``` + +```php +// AppServiceProvider::register() +\Javaabu\Permissions\Permissions::ignoreMigrations(); +``` + +# Adding roles to your user models + +Use the package's `HasRoles` trait, which includes spatie's, on every model that gets roles: + +```php +namespace App\Models; + +use Illuminate\Foundation\Auth\User as Authenticatable; +use Javaabu\Permissions\Traits\HasRoles; + +class User extends Authenticatable +{ + use HasRoles; +} +``` + +# Creating the permissions + +Permissions and roles are declared in code, by this package and by any package that uses it. Write them to the database: ```bash -php artisan vendor:publish --provider="Javaabu\Permissions\PermissionsServiceProvider" --tag="permissions-config" +php artisan permissions:sync ``` -This is the default content of the config file: +Run it on every deploy, after `migrate`. It's safe to run again: it only creates what's missing and updates what's changed. See [Syncing permissions](basic-usage/syncing-permissions.md). + +With no configuration, you get a `super_admin` role on the default guard that has every permission: ```php -// TODO +$user->assignRole('super_admin'); +$user->can('view_roles'); // true +``` + +# Publishing the config + +This is optional. Everything has a default, and the [configuration page](advanced-usage/configuration.md) lists every option. + +```bash +php artisan vendor:publish --tag=permissions-config +``` + +This publishes `config/permissions.php`, the package's own options. spatie/laravel-permission's options stay in `config/permission.php`, which the package preconfigures to use its own `Role` and `Permission` models. To change those, publish them as well: + +```bash +php artisan vendor:publish --tag=permissions-spatie-config ``` + +# Other publishable files + +| Tag | What | +| --- | --- | +| `permissions-config` | `config/permissions.php` | +| `permissions-spatie-config` | `config/permission.php` (spatie's options) | +| `permissions-migrations` | The migrations | +| `permissions-views` | The Blade views, in `resources/views/vendor/permissions` | diff --git a/docs/intro.md b/docs/intro.md index a63e51b..84969f5 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -5,10 +5,24 @@ sidebar_position: 1.0 # Permissions -:::danger +[javaabu/permissions](https://github.com/Javaabu/permissions) adds what Javaabu apps and packages need on top of [spatie/laravel-permission](https://spatie.be/docs/laravel-permission): -This package is currently under development. If anything works, that's a surprise. +- **A registration API.** Apps and packages declare permissions, roles and policies in code, and `php artisan permissions:sync` writes them to the database. +- **Grouped, translatable permissions.** Every permission has a group (such as `exports`) and an English description that doubles as a translation key, so the role form can show "ބެލުން" next to `view_exports`. +- **Overridable policies.** A package ships a default policy; the app replaces it from config or with `Gate::policy()`. A `ResourcePolicy` base class maps `viewAny`, `update`, `forceDelete` and friends to permission names. +- **Multi-auth.** Permissions and roles per guard, any number of user models, and helpers that check against a specific guard. +- **Role management.** Routes, an overridable controller, a JSON API, and Blade or Inertia React pages. -::: +```php +use Javaabu\Permissions\Permissions; -[Permissions](https://github.com/Javaabu/permissions) Modifications brought upon spatie/laravel-permissions. +// in a service provider's boot() +Permissions::resource('exports', 'exports', label: 'Exports'); +Permissions::policy(Export::class, ExportPolicy::class); +``` + +```bash +php artisan permissions:sync +``` + +Start with [Installation & Setup](installation-and-setup.md). If you're writing a package, go to [For package authors](advanced-usage/for-package-authors.md). If you're coming from v2, read [Upgrading](upgrading.md). diff --git a/docs/metadata.json b/docs/metadata.json index 0c3f137..0b3adca 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { "name": "Permissions", - "description": "Modifications brought upon spatie/laravel-permissions", - "version": "2" + "description": "Roles, permissions and policies for Laravel apps and packages, built on spatie/laravel-permission", + "version": "3" } diff --git a/docs/requirements.md b/docs/requirements.md index 73bd6c0..6c37200 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,7 +3,24 @@ title: Requirements sidebar_position: 1.1 --- -This package requires the following: +- PHP 8.4 or higher +- Laravel 12 or 13 +- [spatie/laravel-permission](https://spatie.be/docs/laravel-permission) 7.2 or higher, [javaabu/forms](https://github.com/Javaabu/forms) 2 and [javaabu/manifest](https://github.com/Javaabu/manifest) 1 (installed with the package) -- Laravel 9.0 or higher -- PHP 8.1 or higher +Optional: + +| Package | For | +| --- | --- | +| [spatie/laravel-activitylog](https://spatie.be/docs/laravel-activitylog) ^4.10 or ^5.0 | [Logging role and permission changes](advanced-usage/activity-log.md) | +| [inertiajs/inertia-laravel](https://inertiajs.com) ^2.0 | Rendering the [role pages](basic-usage/managing-roles.md) with Inertia React (with `@javaabu/forms`, `@javaabu/onu`, React 18.3 or 19 and `@inertiajs/react` 2 or 3 in your app) | +| [javaabu/translatable](https://github.com/Javaabu/translatable) ^3.0 | [Translating role descriptions](advanced-usage/translatable-roles.md) | + +Until javaabu/forms v2, javaabu/manifest and javaabu/translatable v3 are on Packagist, add their repositories to your `composer.json`: + +```json +"repositories": [ + { "type": "vcs", "url": "https://github.com/Javaabu/forms" }, + { "type": "vcs", "url": "https://github.com/Javaabu/manifest" }, + { "type": "vcs", "url": "https://github.com/n4ff4h/translatable" } +] +``` diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..034ed5f --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,134 @@ +--- +title: Upgrading from v2 +sidebar_position: 1.25 +--- + +v3 adds the registration API, multi-auth helpers and role management, and drops two dependencies. Most apps need these steps. + +# Requirements + +PHP 8.4 and Laravel 12 or 13. + +```bash +composer require javaabu/permissions:^3.0 +``` + +# New dependencies and a migration + +v3 requires javaabu/forms v2 (the Blade pages) and javaabu/manifest (the page assets). Until they're on Packagist, add their repositories as shown in [Requirements](requirements.md). + +A new migration, `add_translations_to_roles_table`, adds two nullable columns (`translations` and `lang`) to the roles table, for [translatable role descriptions](advanced-usage/translatable-roles.md). It runs with your other migrations and is harmless without translatable. If you publish the package's migrations, publish again to get it. + +# javaabu/helpers and javaabu/activitylog are no longer required + +v2 required both. v3 doesn't, so that apps can move to the next versions of those packages independently. If you use them for anything else, require them yourself. + +- **Activity logging** of `role_updated` and `permissions_updated` now uses spatie/laravel-activitylog directly, when it's installed. javaabu/activitylog installs it (v1 installs v4, v2 installs v5, and both work), so if you still have it, logging keeps working. On v5 the changes are in `attribute_changes` instead of `properties`. See [Activity log](advanced-usage/activity-log.md). +- **`Role` no longer uses `LogsActivity`**, so changes to a role's name and description aren't logged. To keep logging them, [extend the model](advanced-usage/activity-log.md#logging-changes-to-the-role-itself). +- **`Role` no longer implements `AdminModel`.** `admin_url`, `admin_link` and `admin_link_name` are gone. `search()` still works. If you need `AdminModel`, extend the model: + +```php +namespace App\Models; + +use Javaabu\Helpers\AdminModel\AdminModel; +use Javaabu\Helpers\AdminModel\IsAdminModel; +use Javaabu\Permissions\Models\Role as BaseRole; + +class Role extends BaseRole implements AdminModel +{ + use IsAdminModel; + + public function getAdminUrlAttribute(): string + { + return route('admin.roles.edit', $this); + } + + public function getAdminLinkNameAttribute(): string + { + return $this->description; + } +} +``` + +and set it as `permission.models.role` in `config/permission.php`. + +# Factories and seeders moved + +| v2 | v3 | +| --- | --- | +| `Database\Factories\RoleFactory` | `Javaabu\Permissions\Database\Factories\RoleFactory` | +| `Database\Factories\PermissionFactory` | `Javaabu\Permissions\Database\Factories\PermissionFactory` | + +v2 put them in the app's own `Database\Factories` namespace, which could clash with yours. `Role::factory()` and `Permission::factory()` work as before. + +# `HasRoles` + +- `updateRole()` no longer reads `get_setting('default_role')`. Set `default_role` in `config/permissions.php`, or pass it: `$user->updateRole($role, new: true, default_role: get_setting('default_role'))`. It also takes the causer: `causer: $request->user()`. +- `updateRole()` only uses roles of the user's guard. +- The `permission()` scope is now spatie's own, which also counts direct permissions and matches any of the permissions. For v2's behaviour (roles only, all of them), use `whereRoleHasPermissions()`. +- `anyPermission()` takes an optional guard. + +# Events + +- `RolePermissionsUpdated::$role` is typed `Model`, and `$causer` is `?Model` instead of `Illuminate\Foundation\Auth\User`. +- `UserRoleUpdated::$subject` and `$causer` are typed `Model` and `?Model`. +- `Role::syncPermissionIds()` takes an optional causer, ignores ids of other guards, and dispatches only when the permissions changed. + +# `Permissions::$runsMigrations` + +It's now `Permissions::$runs_migrations`. `Permissions::ignoreMigrations()` works as before. + +# Moving your PermissionsSeeder to the registration API + +v2 apps seeded permissions with an array in `database/seeders/PermissionsSeeder.php`. Move them to a service provider: + +```php +// v2: database/seeders/PermissionsSeeder.php +protected $data = [ + 'users' => [ + 'view_users' => 'View users', + 'edit_users' => 'Edit users', + ], +]; +``` + +```php +// v3: app/Providers/AppServiceProvider.php +use Javaabu\Permissions\Permissions; + +public function boot(): void +{ + Permissions::register('users', [ + 'view_users' => 'View users', + 'edit_users' => 'Edit users', + ]); +} +``` + +Set the guard your seeder used (often `web_admin`) in `config/permissions.php`: + +```php +'guards' => ['web_admin'], +``` + +Replace the seeder's body with `$this->call(\Javaabu\Permissions\Database\Seeders\PermissionsSeeder::class)`, or run `php artisan permissions:sync`. If your `RolesSeeder` gave `super_admin` every permission, delete it: the default `roles` config does the same. + +# Policies + +Your app's policies keep working. You can replace the conventional ones with [`ResourcePolicy`](basic-usage/policies.md#resourcepolicy): + +```php +class UserPolicy extends ResourcePolicy +{ + protected string $resource = 'users'; + + protected array $abilities = [ + 'approve' => 'approve_users', + 'updateRole' => 'approve_users', + ]; +} +``` + +# Role management + +v3 has its own [role routes, controller and pages](basic-usage/managing-roles.md), in Blade (javaabu/forms v2) and Inertia React. Your existing `RolesController` and views keep working. To switch, delete them and register `Route::roles(['guard' => 'web_admin'])` in your admin route group, then set `permissions.layout` to your admin layout. v2's views (`admin.roles.*`) aren't used by the package; copy anything you customized into the published `roles` views. diff --git a/lang/dv.json b/lang/dv.json new file mode 100644 index 0000000..304a4df --- /dev/null +++ b/lang/dv.json @@ -0,0 +1,39 @@ +{ + "Other": "އެހެނިހެން", + "Super Admin": "ސުޕަރ އެޑްމިން", + "Roles": "ރޯލްތައް", + "View roles": "ރޯލްތައް ބެލުން", + "Edit roles": "ރޯލްތައް ބަދަލުކުރުން", + "Delete roles": "ރޯލްތައް ފުހެލުން", + "Roles matching ':search'": "':search' އާ ގުޅޭ ރޯލްތައް", + "New role": "އައު ރޯލެއް", + "Role created": "ރޯލް އުފެއްދިއްޖެ", + "Role updated": "ރޯލް ބަދަލުކުރެވިއްޖެ", + "Role deleted": "ރޯލް ފުހެލެވިއްޖެ", + "name": "ނަން", + "description": "ތަފްޞީލު", + "permissions": "ހުއްދަތައް", + "permission": "ހުއްދަ", + "Name": "ނަން", + "Description": "ތަފްޞީލު", + "Permissions": "ހުއްދަތައް", + "Actions": "ޢަމަލުތައް", + "Details": "މަޢުލޫމާތު", + "Guard": "ގާޑު", + "Search roles": "ރޯލްތައް ހޯދާ", + "No roles match your search.": "ހޯދި ގޮތާ ގުޅޭ އެއްވެސް ރޯލެއް ނުފެނުނު.", + "No roles yet": "އަދި އެއްވެސް ރޯލެއް ނެތް", + "Roles you create will appear here.": "ތިބާ އުފައްދާ ރޯލްތައް މިތަނުގައި ފެންނާނެ.", + ":count permissions": ":count ހުއްދަ", + "View": "ބަލާ", + "Edit": "ބަދަލުކުރޭ", + "Delete": "ފުހެލާ", + "Save": "ސޭވްކުރޭ", + "Create role": "ރޯލް އުފައްދާ", + "Back to roles": "ރޯލްތަކަށް އެނބުރި", + "Delete this role? Users with it lose its permissions.": "މި ރޯލް ފުހެލަންވީތޯ؟ މި ރޯލް ލިބިފައިވާ ފަރާތްތަކަށް އޭގެ ހުއްދަތައް ގެއްލޭނެ.", + "Letters, numbers, dashes and underscores, e.g. content_editor": "އިނގިރޭސި އަކުރު، ނަންބަރު، ޑޭޝް އަދި އަންޑަސްކޯ، މިސާލަކަށް content_editor", + "Select all": "ހުރިހާ އެއްޗެއް ޚިޔާރުކުރޭ", + "Select none": "އެއްވެސް އެއްޗެއް ޚިޔާރު ނުކުރާ", + "This role has no permissions.": "މި ރޯލަކަށް އެއްވެސް ހުއްދައެއް ނެތް." +} diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..3a0e74b --- /dev/null +++ b/lang/en.json @@ -0,0 +1,39 @@ +{ + "Other": "Other", + "Super Admin": "Super Admin", + "Roles": "Roles", + "View roles": "View roles", + "Edit roles": "Edit roles", + "Delete roles": "Delete roles", + "Roles matching ':search'": "Roles matching ':search'", + "New role": "New role", + "Role created": "Role created", + "Role updated": "Role updated", + "Role deleted": "Role deleted", + "name": "name", + "description": "description", + "permissions": "permissions", + "permission": "permission", + "Name": "Name", + "Description": "Description", + "Permissions": "Permissions", + "Actions": "Actions", + "Details": "Details", + "Guard": "Guard", + "Search roles": "Search roles", + "No roles match your search.": "No roles match your search.", + "No roles yet": "No roles yet", + "Roles you create will appear here.": "Roles you create will appear here.", + ":count permissions": ":count permissions", + "View": "View", + "Edit": "Edit", + "Delete": "Delete", + "Save": "Save", + "Create role": "Create role", + "Back to roles": "Back to roles", + "Delete this role? Users with it lose its permissions.": "Delete this role? Users with it lose its permissions.", + "Letters, numbers, dashes and underscores, e.g. content_editor": "Letters, numbers, dashes and underscores, e.g. content_editor", + "Select all": "Select all", + "Select none": "Select none", + "This role has no permissions.": "This role has no permissions." +} diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..5c6820e --- /dev/null +++ b/llms.txt @@ -0,0 +1,47 @@ +# javaabu/permissions + +> Roles, permissions and policies for Laravel apps and packages, built on spatie/laravel-permission. Packages and apps register permissions, roles and default policies in code; `php artisan permissions:sync` writes them to the database. Permissions are grouped and have translatable descriptions; policies are overridable; everything works per guard with any number of user models. Includes role management routes, an overridable controller, a JSON API, and Blade (javaabu/forms) or Inertia React (@javaabu/forms) pages that work in Dhivehi and right to left. Role descriptions can be translated with javaabu/translatable. + +Paths are relative to this file. In a project, that's vendor/javaabu/permissions/. + +## Rules + +- Register permissions with `Permissions::register()` or `Permissions::resource()` from `Javaabu\Permissions\Permissions`, in a service provider's `boot()`. Never create permissions with `Permission::create()` in migrations or seeders. +- Run `php artisan permissions:sync` after adding or changing permissions, and on every deploy after `migrate`. Registering alone doesn't touch the database. +- Name permissions `{action}_{resource}`: `view_exports`, `edit_exports`, `delete_exports`, `force_delete_exports`, `edit_others_exports`. The group is the resource (`exports`). +- Descriptions and group labels are English strings used as translation keys. Add their Dhivehi to `lang/dv.json`. +- Packages register default policies with `Permissions::policy(Model::class, Policy::class)`, never `Gate::policy()`, so apps can override them. Apps override with `config('permissions.policies')` or `Gate::policy()`. +- Extend `Javaabu\Permissions\Policies\ResourcePolicy` and set `$resource` for conventional policies. Add other abilities through `$abilities`, which maps an ability to a permission or a list of permissions (any of them). +- Type-hint users as `Illuminate\Database\Eloquent\Model`. Don't assume `App\Models\User` or the `web` guard; guards come from `config('permissions.guards')`. +- User models use `Javaabu\Permissions\Traits\HasRoles`, not spatie's trait directly. +- Role routes come from `Route::roles(['guard' => 'web_admin'])` inside the app's route group; they aren't loaded automatically. +- Role names are ASCII identifiers (`editor`); role descriptions may be in Thaana. +- The role pages render with `config('permissions.views')`: `blade` (javaabu/forms v2 components, extending `permissions.layout`) or `inertia` (React pages `permissions::Roles/Index`, `Create`, `Edit`, `Show`, resolved with javaabu/manifest's `resolvePage()`). Override a Blade view with `vendor:publish --tag=permissions-views`, a React page with `php artisan manifest:override permissions js/Pages/Roles/Edit.tsx`. +- Use logical Tailwind utilities (`ms-`, `pe-`, `text-start`) in overridden views, never `ml-`/`text-left`. +- With javaabu/translatable, set `permission.models.role` to `Javaabu\Permissions\Models\Translatable\Role`; forms then send `description_en`, `description_dv`. Check support with `Javaabu\Permissions\Support\Translations::supportsRole()`, never by calling translatable classes directly. + +## Docs + +- [Introduction](docs/intro.md): what the package does +- [Requirements](docs/requirements.md): PHP, Laravel and optional packages +- [Installation & Setup](docs/installation-and-setup.md): installing, migrations, HasRoles, first sync, publishing +- [Upgrading from v2](docs/upgrading.md): breaking changes and moving a PermissionsSeeder to the registration API +- [Registering permissions](docs/basic-usage/registering-permissions.md): register(), resource(), groups, guards, grants, translations, lookups +- [Syncing permissions](docs/basic-usage/syncing-permissions.md): permissions:sync, --prune, --guard, the seeder, tests +- [Roles](docs/basic-usage/roles.md): defining roles in config and code, updateRole(), syncPermissionIds(), scopes, events +- [Policies](docs/basic-usage/policies.md): Permissions::policy(), overriding, ResourcePolicy, custom abilities, ownership, the role policy +- [Checking permissions](docs/basic-usage/checking-permissions.md): can(), anyPermission(), @anypermission, Permissions::check() +- [Managing roles](docs/basic-usage/managing-roles.md): Route::roles(), authorization, validation, JSON API, setting up the Blade and Inertia React pages, page props, React components +- [For package authors](docs/advanced-usage/for-package-authors.md): a complete package example and rules +- [Multiple guards](docs/advanced-usage/multi-auth.md): guards config, guardFor(), checking against a guard, routes per guard +- [Configuration](docs/advanced-usage/configuration.md): every option in config/permissions.php and spatie's config +- [Overriding controllers and views](docs/advanced-usage/overriding-controllers-and-views.md): custom controllers, Blade view overrides, overriding React pages with manifest:override +- [Activity log](docs/advanced-usage/activity-log.md): optional logging with spatie/laravel-activitylog v4 or v5 +- [Dhivehi and translations](docs/advanced-usage/translations.md): translating descriptions and labels, right to left, strings to review +- [Translatable role descriptions](docs/advanced-usage/translatable-roles.md): optional javaabu/translatable support, the translatable Role model, per-language fields +- [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..8103a22 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "javaabu-permissions-dev", + "private": true, + "description": "Development tooling for the Inertia React pages of javaabu/permissions. 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": { + "@javaabu/forms": "github:Javaabu/react-forms#next-gen", + "@javaabu/onu": "^0.2.1", + "@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/phpunit.xml.dist b/phpunit.xml.dist index 899b1ed..336d993 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,10 +6,20 @@ processIsolation="false" stopOnFailure="false"> - + tests + tests/Translatable + + + + tests/Translatable + + + src + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..44df8bf --- /dev/null +++ b/pint.json @@ -0,0 +1,20 @@ +{ + "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": "alpha" + }, + "php_unit_method_casing": { + "case": "snake_case" + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..34bde1c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3913 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@javaabu/forms': + specifier: github:Javaabu/react-forms#next-gen + version: https://codeload.github.com/Javaabu/react-forms/tar.gz/41eb14a07158a6dedb62bcc4b022507d3910c3e0(@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@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.105.3(@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': + 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: + + '@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.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + 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==} + + '@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/41eb14a07158a6dedb62bcc4b022507d3910c3e0': + resolution: {tarball: https://codeload.github.com/Javaabu/react-forms/tar.gz/41eb14a07158a6dedb62bcc4b022507d3910c3e0} + 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.105.3': + resolution: {integrity: sha512-v1rbNy7MB/4Akth7CQH+CerV13K7Ok9hAusEsfz4sCjDdu3/kOSJPoWfoB+YjdEX1eBFBWKNAGE5nqZCgdlHDw==} + + '@tanstack/react-query@5.105.3': + resolution: {integrity: sha512-dj5hGWZmwoc9NG4enoXuYbiYwb5FxgJYPVv06eUgicMTfEMTRXn08Xv9D/r1V8TfjAv+BpxdvZFzJ1h3hSWZcQ==} + 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/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/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@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==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + 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 + + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + + 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'} + + 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==} + + 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-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + 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 + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + 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@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'} + + 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 + + libphonenumber-js@1.13.15: + resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==} + + 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.68.0: + resolution: {integrity: sha512-9i7NKWm2QlOprMhcwkhBOlIt9zB7cp2GIyFY+oMue0EsprolUFxva5rkdBbyfs4k1eejpa0JRnRgVOGiart+Xg==} + 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==} + + 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'} + + 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==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=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'} + + 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==} + + 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-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: + + '@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.10': {} + + '@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': {} + + '@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/41eb14a07158a6dedb62bcc4b022507d3910c3e0(@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@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: + '@javaabu/onu': 0.2.1(@tanstack/react-query@5.105.3(@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.105.3(@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.105.3(@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.68.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.105.3': {} + + '@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@tanstack/query-core': 5.105.3 + 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.10 + '@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/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.10 + '@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/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@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 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + 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' + + country-flag-icons@1.6.20: {} + + 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: {} + + dequal@2.0.3: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + 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-module-lexer@1.7.0: {} + + 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 + + fsevents@2.3.3: + optional: true + + get-nonce@1.0.1: {} + + 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@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 + + 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 + + libphonenumber-js@1.13.15: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lucide-react@1.68.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 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + nwsapi@2.2.28: {} + + object-assign@4.1.1: {} + + 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 + + punycode@2.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): + 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: {} + + 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: {} + + 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-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..9711199 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,11 @@ +{{-- + javaabu/permissions guidelines for AI code assistants + + Maintained by: Javaabu (https://javaabu.com) +--}} + +## javaabu/permissions + +- This project manages roles, permissions and policies with `javaabu/permissions`, built on spatie/laravel-permission. Register permissions in code with `Javaabu\Permissions\Permissions::register()` or `::resource()`, then run `php artisan permissions:sync`. Never create permissions in migrations or seeders. +- Always activate the `javaabu-permissions` skill when adding or checking permissions, writing a policy, defining roles, registering role routes, or working with multiple guards. +- The role pages render as Blade (javaabu/forms) or Inertia React (`permissions::Roles/*`, through javaabu/manifest), set by `config('permissions.views')`. Override them with `vendor:publish --tag=permissions-views` or `php artisan manifest:override permissions `; don't copy them. diff --git a/resources/boost/skills/javaabu-permissions/SKILL.md b/resources/boost/skills/javaabu-permissions/SKILL.md new file mode 100644 index 0000000..1efe7c8 --- /dev/null +++ b/resources/boost/skills/javaabu-permissions/SKILL.md @@ -0,0 +1,135 @@ +--- +name: javaabu-permissions +description: Register permissions, roles and policies with javaabu/permissions (built on spatie/laravel-permission) in a Laravel app or package. Use whenever the project depends on javaabu/permissions and the task adds a permission, a policy or an ability, defines or assigns roles, checks permissions in PHP or Blade, registers the role management routes, overrides a package's policy or controller, or involves several guards or user models. +license: MIT +metadata: + author: Javaabu +--- + +# Permissions with javaabu/permissions + +Permissions, roles and default policies are declared in code, in a service provider's `boot()`. `php artisan permissions:sync` writes them to the database. Everything from spatie/laravel-permission still works (`can()`, `assignRole()`, `hasRole()`, middleware). The docs ship with the package: `vendor/javaabu/permissions/llms.txt` lists every page in `vendor/javaabu/permissions/docs/`. + +## Adding permissions for a model + +```php +use Javaabu\Permissions\Permissions; + +public function boot(): void +{ + // view_exports, edit_exports, delete_exports, force_delete_exports + Permissions::resource('exports', 'exports', label: 'Exports'); + + Permissions::register('exports', [ + 'download_exports' => 'Download exports', + ]); +} +``` + +Then run `php artisan permissions:sync`, and add the Dhivehi for every description and label to `lang/dv.json`: + +```json +{ + "Exports": "އެކްސްޕޯޓްތައް", + "Download exports": "އެކްސްޕޯޓްތައް ޑައުންލޯޑުކުރުން" +} +``` + +## Writing a policy + +Extend `ResourcePolicy` for the conventional abilities. Map extra abilities in `$abilities`: + +```php +namespace App\Policies; + +use Javaabu\Permissions\Policies\ResourcePolicy; + +class ExportPolicy extends ResourcePolicy +{ + protected string $resource = 'exports'; + + protected array $abilities = [ + 'download' => 'download_exports', + ]; +} +``` + +| Ability | Permission | +| --- | --- | +| `viewAny`, `view` | `view_exports` | +| `create`, `update` | `edit_exports` | +| `delete` | `delete_exports` | +| `restore`, `viewTrash` | `delete_exports` or `force_delete_exports` | +| `forceDelete` | `force_delete_exports` | + +For records users own, set `protected bool $owned = true;`. `update` and `delete` then need `edit_others_x` / `delete_others_x`, or `edit_x` / `delete_x` plus `$model->isOwnedBy($user)`. Write a method for anything that isn't just a permission; inside it, `$this->allows($user, 'perm')` checks any of the given permissions. + +In an app, register the policy as usual (`Gate::policy()`, or Laravel's discovery). **In a package, use `Permissions::policy(Export::class, ExportPolicy::class)`**, so the app can override it. + +## Rules + +- Name permissions `{action}_{resource}`, with the resource in snake_case plural: `view_blog_posts`, `edit_others_oauth_clients`. +- Never `Permission::create()` or `firstOrCreate()` permissions in migrations, seeders or controllers. Register them, and sync. +- Never `Gate::policy()` from a package. Apps override package policies with `config('permissions.policies')` or `Gate::policy()`. +- Type-hint users as `Illuminate\Database\Eloquent\Model` in policies and helpers. Don't assume `App\Models\User` or the `web` guard. +- Relate records to users with a morph (`owner_type`/`owner_id`), not a `user_id` that assumes one model. +- User models use `Javaabu\Permissions\Traits\HasRoles`. +- Authorize with policies (`$this->authorize('update', $export)`) rather than checking permission names in controllers. +- Write descriptions as short English sentences ("View exports"). They're translation keys. + +## Roles + +Roles the app always needs go in `config/permissions.php`; the sync creates them for every guard: + +```php +'roles' => [ + 'super_admin' => ['description' => 'Super Admin', 'permissions' => '*'], + 'editor' => ['description' => 'Editor', 'permissions' => ['view_*', 'edit_posts']], +], +``` + +`'*'` keeps a role synced to every permission. A list of names and `Str::is()` patterns only ever adds permissions. To change roles from a form, use `$user->updateRole($roles, causer: $request->user())`, which checks the causer's `updateRole` ability on the user class. + +## Checking permissions + +```php +$user->can('view_exports'); +$user->anyPermission(['edit_exports', 'delete_exports']); +$user->anyPermission(['view_reports'], 'web_admin'); // that guard only +``` + +```blade +@can('update', $export) … @endcan +@anypermission(['edit_exports', 'delete_exports']) … @endanypermission +``` + +## Role management routes + +They aren't loaded automatically. Register them inside the app's admin group, with the guard they manage: + +```php +Route::middleware('web')->prefix('admin')->name('admin.')->group(function () { + Route::roles(['guard' => 'web_admin']); // admin.roles.index, …; adds auth:web_admin +}); +``` + +Every action also answers JSON. Pages render with `config('permissions.views')`: + +- `blade`: javaabu/forms v2 views extending `config('permissions.layout')` (`layouts.app`), section `content`. The app needs manifest's Vite plugin (`vendor/javaabu/manifest/dist/vite.js`) and `@source "../../vendor/javaabu/permissions/resources/views";` in its CSS. No React packages: permissions declares its npm deps `for: 'pages'`, so `manifest:check` doesn't ask Blade apps for them. +- `inertia`: React pages `permissions::Roles/Index`, `Create`, `Edit`, `Show`. The app resolves them with `resolvePage(name, import.meta.glob("./pages/**/*.tsx"))` from `@javaabu/manifest/inertia`, wraps the app in @javaabu/forms' `FormsProvider`, and installs what `php artisan manifest:check` lists (`@javaabu/forms`, `@javaabu/onu`, React, `@inertiajs/react`; add `--scope=pages` if `@inertiajs/react` isn't in `package.json` yet). + +Override a Blade view with `php artisan vendor:publish --tag=permissions-views`; a React page with `php artisan manifest:override permissions js/Pages/Roles/Edit.tsx`. Reuse the form in your own React pages with `import { RoleForm, PermissionGroupFields } from "@javaabu/permissions"`. Override the controller with `config('permissions.controllers.roles')` and a subclass of `Javaabu\Permissions\Http\Controllers\RolesController` (`meta()` for extra props). In overridden views use logical utilities (`ms-`, `text-start`) so pages work right to left. + +## Translated role descriptions + +With javaabu/translatable installed, set `permission.models.role` to `Javaabu\Permissions\Models\Translatable\Role`. The forms then send `description_en`, `description_dv` (validated with the primary language required), and `RoleResource` adds `translations`. Check with `Javaabu\Permissions\Support\Translations::supportsRole($role)`; never reference translatable classes directly, since it's optional. + +## Multiple guards + +Set `'guards' => ['web_admin']` (or several) in `config/permissions.php`. Registered permissions and configured roles are created for each one. Limit a group with `->guards('web_admin')`. A user only gets roles of its own guard; use `Permissions::guardFor($user)` to see which. + +## Tests + +Sync in `setUp()` (`$this->artisan('permissions:sync')`), then give users roles or permissions. Test both guards when the feature is used by more than one user model. + +More: [references/package-authors.md](references/package-authors.md) for writing a package that ships permissions and policies, and [references/common-mistakes.md](references/common-mistakes.md). diff --git a/resources/boost/skills/javaabu-permissions/references/common-mistakes.md b/resources/boost/skills/javaabu-permissions/references/common-mistakes.md new file mode 100644 index 0000000..f2e557a --- /dev/null +++ b/resources/boost/skills/javaabu-permissions/references/common-mistakes.md @@ -0,0 +1,22 @@ +# Common mistakes + +| Mistake | Instead | +| --- | --- | +| `Permission::create(['name' => 'view_exports'])` in a migration or seeder | `Permissions::resource('exports')` in a provider, then `php artisan permissions:sync` | +| `Gate::policy(Export::class, ExportPolicy::class)` in a package provider | `Permissions::policy(Export::class, ExportPolicy::class)` | +| Registering in `register()` | Register in `boot()` | +| `public function view(User $user, …)` with `App\Models\User` in a package | `public function view(Model $user, …)` | +| `$table->foreignId('user_id')` for an owner | `$table->nullableMorphs('owner')` and `isOwnedBy(Model $user)` | +| `use Spatie\Permission\Traits\HasRoles;` on a user model | `use Javaabu\Permissions\Traits\HasRoles;` | +| `'permissions' => ['view_exports']` on `super_admin` | `'permissions' => '*'` keeps it synced to everything | +| Expecting a pattern role to lose permissions on sync | Only `'*'` roles are synced exactly; others only gain permissions | +| Forgetting the sync after registering | Registering doesn't touch the database; run `php artisan permissions:sync` (and in tests) | +| `@if(auth()->user()->can('a') \|\| auth()->user()->can('b'))` | `@anypermission(['a', 'b'])` | +| Role names in Thaana | Names are ASCII identifiers; put Thaana in the description | +| Expecting `--prune` to remove hand-made permissions | It only prunes groups that are registered | +| `Route::roles()` without a guard in a multi-guard app | `Route::roles(['guard' => 'web_admin'])` | +| `get_setting('default_role')` inside `updateRole()` | `config('permissions.default_role')` or `default_role:` | +| Copying the package's React pages into `resources/js/pages` | `php artisan manifest:override permissions js/Pages/Roles/Edit.tsx`, which `resolvePage()` picks up | +| `ml-2`, `text-left` in an overridden role view | Logical utilities (`ms-2`, `text-start`), so the page works right to left | +| `` straight in a view of a package that supports translatable optionally | Put it in its own partial included only when `Translations::supportsRole()`, since Blade compiles every component tag in a view | +| Sending `description` when role descriptions are translated | Send `description_en`, `description_dv`, … (the names in the form's `translations.inputs`) | diff --git a/resources/boost/skills/javaabu-permissions/references/package-authors.md b/resources/boost/skills/javaabu-permissions/references/package-authors.md new file mode 100644 index 0000000..9666b4c --- /dev/null +++ b/resources/boost/skills/javaabu-permissions/references/package-authors.md @@ -0,0 +1,35 @@ +# Shipping permissions and policies from a package + +Every Javaabu package with controllers ships its permissions and default policy through javaabu/permissions. + +```php +namespace Javaabu\Exports; + +use Illuminate\Support\ServiceProvider; +use Javaabu\Exports\Models\Export; +use Javaabu\Exports\Policies\ExportPolicy; +use Javaabu\Permissions\Permissions; + +class ExportsServiceProvider extends ServiceProvider +{ + public function boot(): void + { + $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); + + Permissions::resource('exports', 'exports', label: 'Exports') + ->add(['download_exports' => 'Download exports']); + + Permissions::policy(Export::class, ExportPolicy::class); + } +} +``` + +- Register in `boot()`, never in `register()`. +- Ship `lang/dv.json` with every description and the label. +- Policies are applied after the app boots. The app wins with `config('permissions.policies')` or `Gate::policy()` in any provider. +- Don't name guards unless a permission only makes sense for one; the app chooses them in `config('permissions.guards')`. +- `->grantTo('editor')` only grants to roles that exist; only use it for roles the app is documented to define. +- Controllers authorize through the policy: `$this->authorize('download', $export)`. +- Routes are a macro the app calls inside its own group, taking `guard` and `middleware` options. Look at `Route::roles()` in `src/Routing/RoleRoutes.php` for the pattern: it stores the guard in the route action (`$request->route()->getAction('permissions_guard')`), defaults middleware to `auth:{guard}`, and reads the controller from config. +- Test with the permissions synced and with two guards (`web` → `User`, `web_admin` → `Admin`). +- Document the permissions you register, and tell apps to run `php artisan permissions:sync` after installing. diff --git a/resources/js/Pages/Roles/Create.tsx b/resources/js/Pages/Roles/Create.tsx new file mode 100644 index 0000000..454bc15 --- /dev/null +++ b/resources/js/Pages/Roles/Create.tsx @@ -0,0 +1,21 @@ +import { LinkButton } from "@javaabu/forms"; +import { RoleForm } from "../../components/RoleForm"; +import type { RoleFormProps } from "../../types"; + +/** permissions::Roles/Create — a new role for the guard */ +export default function Create(props: RoleFormProps) { + const { strings, urls } = props; + + return ( +
+
+

{props.title}

+ + {strings.back} + +
+ + +
+ ); +} diff --git a/resources/js/Pages/Roles/Edit.tsx b/resources/js/Pages/Roles/Edit.tsx new file mode 100644 index 0000000..a46f385 --- /dev/null +++ b/resources/js/Pages/Roles/Edit.tsx @@ -0,0 +1,37 @@ +import { LinkButton } from "@javaabu/forms"; +import { RoleActions } from "../../components/RoleActions"; +import { RoleForm } from "../../components/RoleForm"; +import type { RoleData, RoleFormProps } from "../../types"; + +/** permissions::Roles/Edit — a role's description and permissions */ +export default function Edit(props: RoleFormProps) { + const { strings, urls } = props; + const role = props.role as RoleData; + + return ( +
+
+

+ {props.title} +

+ + {strings.back} + +
+ + + + {props.can.delete ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/resources/js/Pages/Roles/Index.tsx b/resources/js/Pages/Roles/Index.tsx new file mode 100644 index 0000000..419759f --- /dev/null +++ b/resources/js/Pages/Roles/Index.tsx @@ -0,0 +1,76 @@ +import { LinkButton, Pagination, SearchForm, Table, TableCell, TableEmptyRow, TableHeading, TableRow } from "@javaabu/forms"; +import { RoleActions } from "../../components/RoleActions"; +import type { RolesIndexProps } from "../../types"; + +/** permissions::Roles/Index — the guard's roles, with search */ +export default function Index(props: RolesIndexProps) { + const { strings, urls, roles } = props; + + return ( +
+
+

{props.title}

+ {props.can.create ? ( + + {strings.new_role} + + ) : null} +
+ + + + + + + + + + } + pagination={roles.meta.last_page > 1 ? : null} + > + {roles.data.length ? ( + roles.data.map((role) => ( + + + {role.can.view && role.links ? ( + + {role.name} + + ) : ( + + {role.name} + + )} + + + {role.translated_description} + + + {strings.permissions_count.replace(":count", String(role.permissions_count ?? 0))} + + + + + + )) + ) : ( + + {props.search ? ( +

{strings.no_results}

+ ) : ( + <> +

{strings.no_roles}

+

{strings.no_roles_message}

+ + )} +
+ )} +
+
+ ); +} diff --git a/resources/js/Pages/Roles/Show.tsx b/resources/js/Pages/Roles/Show.tsx new file mode 100644 index 0000000..4823e7b --- /dev/null +++ b/resources/js/Pages/Roles/Show.tsx @@ -0,0 +1,73 @@ +import { Card, LinkButton } from "@javaabu/forms"; +import { RoleActions } from "../../components/RoleActions"; +import type { RolesShowProps } from "../../types"; + +function CheckIcon() { + return ( + + ); +} + +/** permissions::Roles/Show — a role and the permissions it grants */ +export default function Show(props: RolesShowProps) { + const { strings, urls, role } = props; + + return ( +
+
+

+ {props.title} +

+ + {strings.back} + +
+ + }> +
+
+
{strings.name}
+
+ {role.name} +
+
+
+
{strings.description}
+
{role.translated_description}
+
+
+
{strings.guard}
+
+ {props.guard} +
+
+
+
+ +
+

{strings.permissions}

+ + {props.permission_groups.length ? ( + props.permission_groups.map((group) => ( +
+ +
    + {group.permissions.map((permission) => ( +
  • + + {permission.description} +
  • + ))} +
+
+
+ )) + ) : ( +

{strings.no_permissions}

+ )} +
+
+ ); +} diff --git a/resources/js/components/PermissionGroupFields.tsx b/resources/js/components/PermissionGroupFields.tsx new file mode 100644 index 0000000..f7ae278 --- /dev/null +++ b/resources/js/components/PermissionGroupFields.tsx @@ -0,0 +1,71 @@ +import { Button, Card, Checkbox, Errors } from "@javaabu/forms"; +import { useState } from "react"; +import type { PermissionGroup, RolesStrings } from "../types"; + +export interface PermissionGroupFieldsProps { + groups: PermissionGroup[]; + strings: Pick; +} + +/** A card of permission checkboxes (`permissions[]`) per group, with select all and none */ +export function PermissionGroupFields({ groups, strings }: PermissionGroupFieldsProps) { + const [selected, setSelected] = useState>( + () => new Set(groups.flatMap((group) => group.permissions.filter((permission) => permission.granted).map((permission) => permission.id))), + ); + + const toggle = (ids: number[], on: boolean) => + setSelected((current) => { + const next = new Set(current); + ids.forEach((id) => (on ? next.add(id) : next.delete(id))); + return next; + }); + + return ( +
+

{strings.permissions}

+ + {groups.map((group) => { + const ids = group.permissions.map((permission) => permission.id); + + return ( +
+ +

{group.label}

+
+ + +
+
+ } + > +
+ {group.permissions.map((permission) => ( +
+ toggle([permission.id], on)} + /> +
+ ))} +
+ + + ); + })} + + +
+ ); +} diff --git a/resources/js/components/RoleActions.tsx b/resources/js/components/RoleActions.tsx new file mode 100644 index 0000000..96c30fd --- /dev/null +++ b/resources/js/components/RoleActions.tsx @@ -0,0 +1,53 @@ +import { Form, LinkButton, Submit } from "@javaabu/forms"; +import type { MouseEvent } from "react"; +import type { RoleData, RolesStrings } from "../types"; + +export interface RoleActionsProps { + role: RoleData; + strings: Pick; + /** Show the View button. Default `false` (the name links to the role) */ + showView?: boolean; + /** Show the Edit button. Default `true` */ + showEdit?: boolean; +} + +function confirmFirst(message: string) { + return (event: MouseEvent) => { + if (!window.confirm(message)) { + event.preventDefault(); + } + }; +} + +/** View, edit and delete buttons, shown when the user may use them */ +export function RoleActions({ role, strings, showView = false, showEdit = true }: RoleActionsProps) { + const links = role.links; + + if (!links) { + return null; + } + + return ( +
+ {showView && role.can.view ? ( + + {strings.view} + + ) : null} + + {showEdit && role.can.update ? ( + + {strings.edit} + + ) : null} + + {role.can.delete ? ( +
+ + {strings.delete} + +
+ ) : null} +
+ ); +} diff --git a/resources/js/components/RoleForm.tsx b/resources/js/components/RoleForm.tsx new file mode 100644 index 0000000..1342618 --- /dev/null +++ b/resources/js/components/RoleForm.tsx @@ -0,0 +1,58 @@ +import { Card, Form, Submit, Text } from "@javaabu/forms"; +import type { ReactNode } from "react"; +import type { PermissionGroup, RoleData, RolesStrings, RoleTranslations } from "../types"; +import { PermissionGroupFields } from "./PermissionGroupFields"; + +export interface RoleFormProps { + /** null on the create page */ + role: RoleData | null; + action: string; + permissionGroups: PermissionGroup[]; + translations: RoleTranslations | null; + strings: Pick< + RolesStrings, + "details" | "name" | "name_help" | "description" | "permissions" | "select_all" | "select_none" | "save" | "create" + >; + /** Replaces the submit button */ + actions?: ReactNode; +} + +/** The role's name, description (one per locale when translated) and permissions */ +export function RoleForm({ role, action, permissionGroups, translations, strings, actions }: RoleFormProps) { + const model = role ? { ...role, ...(translations?.values ?? {}) } : (translations?.values ?? undefined); + + return ( +
+ +
+ + + {translations ? ( +
+ {translations.inputs.description.map((input) => ( + + ))} +
+ ) : ( + + )} +
+
+ + + +
+ {actions ?? {role ? strings.save : strings.create}} +
+ + ); +} diff --git a/resources/js/index.ts b/resources/js/index.ts new file mode 100644 index 0000000..360fad3 --- /dev/null +++ b/resources/js/index.ts @@ -0,0 +1,5 @@ +// What apps import as @javaabu/permissions (through javaabu/manifest) +export { PermissionGroupFields, type PermissionGroupFieldsProps } from "./components/PermissionGroupFields"; +export { RoleActions, type RoleActionsProps } from "./components/RoleActions"; +export { RoleForm, type RoleFormProps as RoleFormComponentProps } from "./components/RoleForm"; +export type * from "./types"; diff --git a/resources/js/roles.ts b/resources/js/roles.ts new file mode 100644 index 0000000..aff1273 --- /dev/null +++ b/resources/js/roles.ts @@ -0,0 +1,54 @@ +/** + * For the Blade role pages. + * + * Buttons marked data-permission-group-select="all" or "none" tick or untick every permission + * in their group (data-permission-group). Forms with data-confirm ask before they submit. + */ + +export function selectGroup(group: Element, checked: boolean): void { + group.querySelectorAll('input[type="checkbox"][name="permissions[]"]').forEach((checkbox) => { + if (checkbox.disabled || checkbox.checked === checked) { + return; + } + + checkbox.checked = checked; + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function onClick(event: Event): void { + const button = (event.target as Element | null)?.closest?.("[data-permission-group-select]"); + + if (!button) { + return; + } + + const group = button.closest("[data-permission-group]"); + + if (!group) { + return; + } + + event.preventDefault(); + selectGroup(group, button.dataset.permissionGroupSelect === "all"); +} + +function onSubmit(event: Event): void { + const form = event.target as HTMLFormElement | null; + const message = form?.dataset?.confirm; + + if (message && !window.confirm(message)) { + event.preventDefault(); + event.stopImmediatePropagation(); + } +} + +export function init(root: Document = document): void { + root.addEventListener("click", onClick); + root.addEventListener("submit", onSubmit, true); +} + +if (typeof document !== "undefined" && !(window as { __javaabuPermissionsRoles?: boolean }).__javaabuPermissionsRoles) { + (window as { __javaabuPermissionsRoles?: boolean }).__javaabuPermissionsRoles = true; + init(); +} diff --git a/resources/js/types.ts b/resources/js/types.ts new file mode 100644 index 0000000..99a85ac --- /dev/null +++ b/resources/js/types.ts @@ -0,0 +1,141 @@ +/** A permission as PermissionResource serializes it */ +export interface PermissionData { + id: number; + name: string; + description: string | null; + translated_description: string; + group: string | null; + guard_name: string; +} + +/** A role as RoleResource serializes it */ +export interface RoleData { + id: number; + name: string; + description: string | null; + translated_description: string; + guard_name: string; + /** Every locale's description, when javaabu/translatable translates descriptions */ + translations?: Record; + permissions_count?: number; + permissions?: PermissionData[]; + can: { view: boolean; update: boolean; delete: boolean }; + links?: { show: string; edit: string; update: string; destroy: string }; + created_at: string | null; + updated_at: string | null; +} + +/** One permission on the role form */ +export interface PermissionOption { + id: number; + name: string; + /** Translated */ + description: string; + granted: boolean; +} + +/** Permissions of one registered group, in registration order */ +export interface PermissionGroup { + name: string; + /** Translated */ + label: string; + permissions: PermissionOption[]; +} + +/** One description input per locale, from javaabu/translatable */ +export interface LocaleInputData { + field: string; + /** e.g. `description_dv` */ + name: string; + locale: string; + direction: "ltr" | "rtl"; + thaana: boolean; + primary: boolean; + /** "Description (ދިވެހި)" */ + label: string; + language: Record; +} + +export interface RoleTranslations { + values: Record; + inputs: { description: LocaleInputData[] }; +} + +export interface RoleRoutes { + index: string; + create: string; + store: string; + show: string; + edit: string; + update: string; + destroy: string; +} + +export interface RolesStrings { + roles: string; + new_role: string; + search: string; + name: string; + name_help: string; + description: string; + permissions: string; + permissions_count: string; + actions: string; + view: string; + edit: string; + delete: string; + save: string; + create: string; + back: string; + confirm_delete: string; + no_roles: string; + no_roles_message: string; + no_results: string; + select_all: string; + select_none: string; + no_permissions: string; + details: string; + guard: string; +} + +export interface Paginated { + data: T[]; + links: Record; + meta: { + current_page: number; + last_page: number; + per_page: number; + total: number; + from: number | null; + to: number | null; + path: string; + links: Array<{ url: string | null; label: string; active: boolean }>; + }; +} + +/** Props every page gets */ +export interface RolesPageProps { + title: string; + guard: string; + /** Route names, e.g. `routes.edit` is `admin.roles.edit` */ + routes: RoleRoutes; + urls: { index: string; create: string; store: string; show?: string; edit?: string; update?: string; destroy?: string }; + can: { create: boolean; update: boolean; delete: boolean }; + strings: RolesStrings; +} + +export interface RolesIndexProps extends RolesPageProps { + roles: Paginated; + search: string | null; +} + +export interface RoleFormProps extends RolesPageProps { + role: RoleData | null; + permission_groups: PermissionGroup[]; + translations: RoleTranslations | null; +} + +export interface RolesShowProps extends RolesPageProps { + role: RoleData; + permission_groups: PermissionGroup[]; +} diff --git a/resources/views/roles/create.blade.php b/resources/views/roles/create.blade.php new file mode 100644 index 0000000..a7b0087 --- /dev/null +++ b/resources/views/roles/create.blade.php @@ -0,0 +1,24 @@ +@extends(config('permissions.layout', 'layouts.app')) + +@section(config('permissions.layout_section', 'content')) + @manifestRequire('permissions::roles') + +
+
+

{{ $title }}

+ + {{ __('Back to roles') }} + +
+ + @include('permissions::roles.partials.alerts') + + + @include('permissions::roles.partials.form', ['role' => null]) + +
+ {{ __('Create role') }} +
+
+
+@endsection diff --git a/resources/views/roles/edit.blade.php b/resources/views/roles/edit.blade.php new file mode 100644 index 0000000..bf561a5 --- /dev/null +++ b/resources/views/roles/edit.blade.php @@ -0,0 +1,30 @@ +@extends(config('permissions.layout', 'layouts.app')) + +@section(config('permissions.layout_section', 'content')) + @manifestRequire('permissions::roles') + +
+
+

{{ $title }}

+ + {{ __('Back to roles') }} + +
+ + @include('permissions::roles.partials.alerts') + + + @include('permissions::roles.partials.form', ['role' => $role]) + +
+ {{ __('Save') }} +
+
+ + @if($can['delete']) +
+ @include('permissions::roles.partials.actions', ['role' => $role, 'show_edit' => false]) +
+ @endif +
+@endsection diff --git a/resources/views/roles/index.blade.php b/resources/views/roles/index.blade.php new file mode 100644 index 0000000..cb07895 --- /dev/null +++ b/resources/views/roles/index.blade.php @@ -0,0 +1,51 @@ +@extends(config('permissions.layout', 'layouts.app')) + +@section(config('permissions.layout_section', 'content')) + @manifestRequire('permissions::roles') + +
+
+

{{ $title }}

+ + @if($can['create']) + + {{ __('New role') }} + + @endif +
+ + @include('permissions::roles.partials.alerts') + + + + + + + + + + + + + @forelse($roles as $role) + @include('permissions::roles.partials.row', ['role' => $role]) + @empty + + @if($search) +

{{ __('No roles match your search.') }}

+ @else +

{{ __('No roles yet') }}

+

{{ __('Roles you create will appear here.') }}

+ @endif +
+ @endforelse +
+ + @if($roles->hasPages()) + + + + @endif +
+
+@endsection diff --git a/resources/views/roles/partials/actions.blade.php b/resources/views/roles/partials/actions.blade.php new file mode 100644 index 0000000..988b919 --- /dev/null +++ b/resources/views/roles/partials/actions.blade.php @@ -0,0 +1,20 @@ +@php($user = auth()->user()) +
+ @if(($show_view ?? false) && $user?->can('view', $role)) + + {{ __('View') }} + + @endif + + @if(($show_edit ?? true) && $user?->can('update', $role)) + + {{ __('Edit') }} + + @endif + + @if($user?->can('delete', $role)) + + {{ __('Delete') }} + + @endif +
diff --git a/resources/views/roles/partials/alerts.blade.php b/resources/views/roles/partials/alerts.blade.php new file mode 100644 index 0000000..baa831a --- /dev/null +++ b/resources/views/roles/partials/alerts.blade.php @@ -0,0 +1,7 @@ +@if(session('success')) + {{ session('success') }} +@endif + +@if(session('error')) + {{ session('error') }} +@endif diff --git a/resources/views/roles/partials/form.blade.php b/resources/views/roles/partials/form.blade.php new file mode 100644 index 0000000..378921e --- /dev/null +++ b/resources/views/roles/partials/form.blade.php @@ -0,0 +1,51 @@ +{{-- The fields of the create and edit pages; $role is null on the create page --}} + +
+ + {{ __('Letters, numbers, dashes and underscores, e.g. content_editor') }} + + + @if($translations) + {{-- In its own view, so it only compiles when javaabu/translatable is installed --}} + @include('permissions::roles.partials.translatable-description') + @else + + @endif +
+
+ +
+

{{ __('Permissions') }}

+ + @foreach($permission_groups as $group) + + {{ $group['label'] }} + + {{ __('Select all') }} + {{ __('Select none') }} + + +
+ @foreach($group['permissions'] as $permission) + + @endforeach +
+
+ @endforeach + + +
diff --git a/resources/views/roles/partials/row.blade.php b/resources/views/roles/partials/row.blade.php new file mode 100644 index 0000000..36e9d33 --- /dev/null +++ b/resources/views/roles/partials/row.blade.php @@ -0,0 +1,19 @@ +@php($user = auth()->user()) + + + @if($user?->can('view', $role)) + {{ $role->name }} + @else + {{ $role->name }} + @endif + + + {{ $role->translated_description }} + + + {{ __(':count permissions', ['count' => $role->permissions_count ?? $role->permissions()->count()]) }} + + + @include('permissions::roles.partials.actions', ['role' => $role, 'show_view' => false]) + + diff --git a/resources/views/roles/partials/translatable-description.blade.php b/resources/views/roles/partials/translatable-description.blade.php new file mode 100644 index 0000000..754d406 --- /dev/null +++ b/resources/views/roles/partials/translatable-description.blade.php @@ -0,0 +1 @@ + diff --git a/resources/views/roles/show.blade.php b/resources/views/roles/show.blade.php new file mode 100644 index 0000000..498e1eb --- /dev/null +++ b/resources/views/roles/show.blade.php @@ -0,0 +1,56 @@ +@extends(config('permissions.layout', 'layouts.app')) + +@section(config('permissions.layout_section', 'content')) + @manifestRequire('permissions::roles') + +
+
+

{{ $title }}

+ + {{ __('Back to roles') }} + +
+ + @include('permissions::roles.partials.alerts') + + +
+
+
{{ __('Name') }}
+
{{ $role->name }}
+
+
+
{{ __('Description') }}
+
{{ $role->translated_description }}
+
+
+
{{ __('Guard') }}
+
{{ $guard }}
+
+
+ + + @include('permissions::roles.partials.actions', ['role' => $role]) + +
+ +
+

{{ __('Permissions') }}

+ + @forelse($permission_groups as $group) + +
    + @foreach($group['permissions'] as $permission) +
  • + + {{ $permission['description'] }} +
  • + @endforeach +
+
+ @empty +

{{ __('This role has no permissions.') }}

+ @endforelse +
+
+@endsection diff --git a/src/Commands/SyncPermissionsCommand.php b/src/Commands/SyncPermissionsCommand.php new file mode 100644 index 0000000..55ef361 --- /dev/null +++ b/src/Commands/SyncPermissionsCommand.php @@ -0,0 +1,52 @@ +info('Syncing permissions...'); + + $result = $syncer->sync($this->option('guard') ?: null, (bool) $this->option('prune')); + + foreach ($result->created_permissions as $permission) { + $this->line("Created permission {$permission}"); + } + + foreach ($result->updated_permissions as $permission) { + $this->line("Updated permission {$permission}"); + } + + foreach ($result->pruned_permissions as $permission) { + $this->line("Deleted permission {$permission}"); + } + + foreach ($result->created_roles as $role) { + $this->line("Created role {$role}"); + } + + foreach ($result->skipped_grants as $role) { + $this->warn("Skipped grants to {$role}: the role doesn't exist"); + } + + $this->comment(sprintf( + 'Done: %d permissions created, %d updated, %d deleted; %d roles created.', + count($result->created_permissions), + count($result->updated_permissions), + count($result->pruned_permissions), + count($result->created_roles), + )); + + return self::SUCCESS; + } +} diff --git a/src/Events/RolePermissionsUpdated.php b/src/Events/RolePermissionsUpdated.php index f454ac2..b39877c 100644 --- a/src/Events/RolePermissionsUpdated.php +++ b/src/Events/RolePermissionsUpdated.php @@ -2,34 +2,23 @@ namespace Javaabu\Permissions\Events; -use Illuminate\Foundation\Auth\User; -use Illuminate\Queue\SerializesModels; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Events\Dispatchable; -use Javaabu\Permissions\Models\Role; +use Illuminate\Queue\SerializesModels; class RolePermissionsUpdated { use Dispatchable; use SerializesModels; - public Role $role; - public ?User $causer; - public array $old_permissions; - public array $new_permissions; - /** - * Create a new event instance. - * - * @param array $old_permissions - * @param array $new_permissions - * @param Role $role - * @param User|null $causer + * @param array $old_permissions + * @param array $new_permissions */ - public function __construct(array $old_permissions, array $new_permissions, Role $role, User $causer = null) - { - $this->old_permissions = $old_permissions; - $this->new_permissions = $new_permissions; - $this->role = $role; - $this->causer = $causer; - } + public function __construct( + public array $old_permissions, + public array $new_permissions, + public Model $role, + public ?Model $causer = null, + ) {} } diff --git a/src/Events/UserRoleUpdated.php b/src/Events/UserRoleUpdated.php index a58b7e2..2b2399a 100644 --- a/src/Events/UserRoleUpdated.php +++ b/src/Events/UserRoleUpdated.php @@ -2,8 +2,9 @@ namespace Javaabu\Permissions\Events; -use Illuminate\Queue\SerializesModels; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; class UserRoleUpdated @@ -11,39 +12,23 @@ class UserRoleUpdated use Dispatchable; use SerializesModels; - /** - * @var App\Models\User - */ - public $subject; - - /** - * @var App\Models\User - */ - public $causer; - - /** - * @var string|array - */ - public $old_role; + /** @var array */ + public array $old_role; - /** - * @var string|array - */ - public $new_role; + /** @var array */ + public array $new_role; /** - * Create a new event instance. - * - * @param string|array $old_role - * @param string|array $new_role - * @param $subject - * @param null $causer + * @param string|array $old_role + * @param string|array $new_role */ - public function __construct($old_role, $new_role, $subject, $causer = null) - { + public function __construct( + string|array $old_role, + string|array $new_role, + public Model $subject, + public ?Model $causer = null, + ) { $this->old_role = Arr::wrap($old_role); $this->new_role = Arr::wrap($new_role); - $this->subject = $subject; - $this->causer = $causer; } } diff --git a/src/Http/Controllers/RolesController.php b/src/Http/Controllers/RolesController.php new file mode 100644 index 0000000..cf8238f --- /dev/null +++ b/src/Http/Controllers/RolesController.php @@ -0,0 +1,318 @@ +authorizeAbility($request, 'viewAny', $this->roleClass()); + + $search = $request->string('search')->trim()->toString() ?: null; + $per_page = min(max($request->integer('per_page', 20), 1), 100); + + $roles = $this->query($request) + ->search($search) + ->withCount('permissions') + ->orderBy('name') + ->paginate($per_page) + ->withQueryString(); + + if ($request->expectsJson()) { + return RoleResource::collection($roles)->response(); + } + + return $this->views->render('roles.index', [ + ...$this->meta($request), + 'roles' => $this->views->isInertia() + ? RoleResource::collection($roles) + : $roles, + 'search' => $search, + 'title' => $search + ? __('Roles matching \':search\'', ['search' => $search]) + : __('Roles'), + ]); + } + + public function create(Request $request): View|Responsable|JsonResponse + { + $this->authorizeAbility($request, 'create', $this->roleClass()); + + return $this->form($request, null); + } + + public function store(RoleRequest $request): RedirectResponse|JsonResponse + { + $this->authorizeAbility($request, 'create', $this->roleClass()); + + $role_class = $this->roleClass(); + + /** @var Model $role */ + $role = new $role_class; + $role->fill($this->attributes($request)); + $role->forceFill(['guard_name' => RoleRoutes::guard($request)]); + $role->save(); + + $role->syncPermissionIds($request->validated('permissions', []), $request->user()); + + if ($request->expectsJson()) { + return (new RoleResource($role->load('permissions'))) + ->response() + ->setStatusCode(201); + } + + return redirect() + ->route($this->routeName($request, 'edit'), $role) + ->with('success', __('Role created')); + } + + public function show(Request $request, string $role): View|Responsable|JsonResponse + { + $role = $this->findRole($request, $role); + + $this->authorizeAbility($request, 'view', $role); + + $role->load('permissions'); + + if ($request->expectsJson()) { + return (new RoleResource($role))->response(); + } + + return $this->views->render('roles.show', [ + ...$this->meta($request, $role), + 'role' => $this->views->isInertia() + ? (new RoleResource($role))->resolve($request) + : $role, + 'permission_groups' => $this->permission_groups->forRole($role), + 'title' => $role->translated_description, + ]); + } + + public function edit(Request $request, string $role): View|Responsable|JsonResponse + { + $role = $this->findRole($request, $role); + + $this->authorizeAbility($request, 'update', $role); + + return $this->form($request, $role); + } + + public function update(RoleRequest $request, string $role): RedirectResponse|JsonResponse + { + $role = $this->findRole($request, $role); + + $this->authorizeAbility($request, 'update', $role); + + $role->fill($this->attributes($request)); + $role->save(); + + if ($request->has('permissions') || ! $request->expectsJson()) { + $role->syncPermissionIds($request->validated('permissions', []), $request->user()); + } + + if ($request->expectsJson()) { + return (new RoleResource($role->load('permissions')))->response(); + } + + return redirect() + ->route($this->routeName($request, 'edit'), $role) + ->with('success', __('Role updated')); + } + + public function destroy(Request $request, string $role): RedirectResponse|JsonResponse + { + $role = $this->findRole($request, $role); + + $this->authorizeAbility($request, 'delete', $role); + + $role->delete(); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + if ($request->expectsJson()) { + return response()->json(null, 204); + } + + return redirect() + ->route($this->routeName($request, 'index')) + ->with('success', __('Role deleted')); + } + + /** + * The create and edit pages, or their data as JSON + */ + protected function form(Request $request, ?Model $role): View|Responsable|JsonResponse + { + $role?->load('permissions'); + + $data = [ + 'role' => $role ? (new RoleResource($role))->resolve($request) : null, + 'permission_groups' => $this->permission_groups->forGuard(RoleRoutes::guard($request), $role), + 'guard' => RoleRoutes::guard($request), + 'translations' => Translations::form($role), + ]; + + if ($request->expectsJson()) { + return response()->json(['data' => $data]); + } + + if (! $this->views->isInertia()) { + $data['role'] = $role; + } + + return $this->views->render($role ? 'roles.edit' : 'roles.create', [ + ...$this->meta($request, $role), + ...$data, + 'title' => $role ? $role->translated_description : __('New role'), + ]); + } + + /** + * The role's own attributes from the request: name and description, or a description per + * locale when descriptions are translated + * + * @return array + */ + protected function attributes(RoleRequest $request): array + { + return Arr::except($request->validated(), ['permissions']); + } + + /** + * What every page gets: the guard, route names, URLs, what the user may do and, for the + * Inertia pages, their translated text + * + * @return array + */ + protected function meta(Request $request, ?Model $role = null): array + { + $user = $request->user(); + + return [ + 'guard' => RoleRoutes::guard($request), + 'routes' => $this->routeNames($request), + 'urls' => array_filter([ + 'index' => route($this->routeName($request, 'index')), + 'create' => route($this->routeName($request, 'create')), + 'store' => route($this->routeName($request, 'store')), + 'show' => $role ? route($this->routeName($request, 'show'), $role) : null, + 'edit' => $role ? route($this->routeName($request, 'edit'), $role) : null, + 'update' => $role ? route($this->routeName($request, 'update'), $role) : null, + 'destroy' => $role ? route($this->routeName($request, 'destroy'), $role) : null, + ]), + 'can' => [ + 'create' => (bool) $user?->can('create', $this->roleClass()), + 'update' => $role ? (bool) $user?->can('update', $role) : false, + 'delete' => $role ? (bool) $user?->can('delete', $role) : false, + ], + 'strings' => $this->views->isInertia() ? $this->strings() : null, + ]; + } + + /** + * The translated text of the Inertia pages (Blade views use __() directly) + * + * @return array + */ + protected function strings(): array + { + return [ + 'roles' => __('Roles'), + 'new_role' => __('New role'), + 'search' => __('Search roles'), + 'name' => __('Name'), + 'name_help' => __('Letters, numbers, dashes and underscores, e.g. content_editor'), + 'description' => __('Description'), + 'permissions' => __('Permissions'), + 'permissions_count' => __(':count permissions'), + 'actions' => __('Actions'), + 'view' => __('View'), + 'edit' => __('Edit'), + 'delete' => __('Delete'), + 'save' => __('Save'), + 'create' => __('Create role'), + 'back' => __('Back to roles'), + 'confirm_delete' => __('Delete this role? Users with it lose its permissions.'), + 'no_roles' => __('No roles yet'), + 'no_roles_message' => __('Roles you create will appear here.'), + 'no_results' => __('No roles match your search.'), + 'select_all' => __('Select all'), + 'select_none' => __('Select none'), + 'no_permissions' => __('This role has no permissions.'), + 'details' => __('Details'), + 'guard' => __('Guard'), + ]; + } + + /** + * Roles of the guard the route manages + */ + protected function query(Request $request): Builder + { + return $this->roleClass()::query()->where('guard_name', RoleRoutes::guard($request)); + } + + protected function findRole(Request $request, string $role): Model + { + return $this->query($request)->findOrFail($role); + } + + protected function authorizeAbility(Request $request, string $ability, mixed $arguments): void + { + $user = $request->user(); + + abort_unless($user && $user->can($ability, $arguments), 403); + } + + /** @return class-string */ + protected function roleClass(): string + { + return app(PermissionRegistrar::class)->getRoleClass(); + } + + protected function routeName(Request $request, string $action): string + { + return RoleRoutes::namePrefix($request) . ".{$action}"; + } + + /** + * Names of the routes, honouring the prefix the app registered them under + * + * @return array + */ + protected function routeNames(Request $request): array + { + return collect(['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']) + ->mapWithKeys(fn (string $action) => [$action => $this->routeName($request, $action)]) + ->all(); + } +} diff --git a/src/Http/Requests/RoleRequest.php b/src/Http/Requests/RoleRequest.php new file mode 100644 index 0000000..f8d01d3 --- /dev/null +++ b/src/Http/Requests/RoleRequest.php @@ -0,0 +1,84 @@ + */ + public function rules(): array + { + $guard = RoleRoutes::guard($this); + $tables = config('permission.table_names'); + $role = $this->role(); + $required = $this->isMethod('post') ? 'required' : 'sometimes'; + + $rules = [ + 'name' => [ + $required, + 'string', + 'max:255', + 'alpha_dash:ascii', + Rule::unique($tables['roles'], 'name') + ->where('guard_name', $guard) + ->ignore($role?->getKey()), + ], + 'description' => [$required, 'string', 'max:255'], + 'permissions' => ['sometimes', 'array'], + 'permissions.*' => [ + 'integer', + Rule::exists($tables['permissions'], 'id')->where('guard_name', $guard), + ], + ]; + + // description_en, description_dv, … when javaabu/translatable translates descriptions + return Translations::rules($role, $rules); + } + + /** + * The role being updated, if any, from the route's guard + */ + public function role(): ?Model + { + $role = $this->route('role'); + + if ($role === null || $role instanceof Model) { + return $role; + } + + return app(PermissionRegistrar::class)->getRoleClass()::query() + ->where('guard_name', RoleRoutes::guard($this)) + ->find($role); + } + + /** @return array */ + public function attributes(): array + { + $attributes = [ + 'name' => __('name'), + 'description' => __('description'), + 'permissions' => __('permissions'), + 'permissions.*' => __('permission'), + ]; + + foreach (Translations::form($this->role())['inputs']['description'] ?? [] as $input) { + $attributes[$input['name']] = $input['label']; + } + + return $attributes; + } +} diff --git a/src/Http/Resources/PermissionResource.php b/src/Http/Resources/PermissionResource.php new file mode 100644 index 0000000..8b2deb6 --- /dev/null +++ b/src/Http/Resources/PermissionResource.php @@ -0,0 +1,22 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'translated_description' => $this->translated_description, + 'group' => $this->model, + 'guard_name' => $this->guard_name, + ]; + } +} diff --git a/src/Http/Resources/RoleResource.php b/src/Http/Resources/RoleResource.php new file mode 100644 index 0000000..4bbb2eb --- /dev/null +++ b/src/Http/Resources/RoleResource.php @@ -0,0 +1,64 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'translated_description' => $this->translated_description, + 'guard_name' => $this->guard_name, + 'translations' => $this->when(Translations::supportsRole($this->resource), fn () => Translations::values($this->resource)), + 'permissions_count' => $this->whenCounted('permissions'), + // Resolved here, so Inertia props aren't wrapped in `data` like a top-level collection + 'permissions' => $this->whenLoaded('permissions', fn () => PermissionResource::collection($this->permissions)->resolve($request)), + 'can' => $this->abilities($request), + 'links' => $this->when(RoleRoutes::isRoleRoute($request), fn () => $this->links($request)), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } + + /** + * What the current user may do with the role + * + * @return array{view: bool, update: bool, delete: bool} + */ + protected function abilities(Request $request): array + { + $user = $request->user(); + + return [ + 'view' => (bool) $user?->can('view', $this->resource), + 'update' => (bool) $user?->can('update', $this->resource), + 'delete' => (bool) $user?->can('delete', $this->resource), + ]; + } + + /** + * URLs of the role's pages, under the route names the app registered them with + * + * @return array{show: string, edit: string, update: string, destroy: string} + */ + protected function links(Request $request): array + { + $prefix = RoleRoutes::namePrefix($request); + + return [ + 'show' => route("{$prefix}.show", $this->resource), + 'edit' => route("{$prefix}.edit", $this->resource), + 'update' => route("{$prefix}.update", $this->resource), + 'destroy' => route("{$prefix}.destroy", $this->resource), + ]; + } +} diff --git a/src/Listeners/Concerns/LogsChanges.php b/src/Listeners/Concerns/LogsChanges.php new file mode 100644 index 0000000..c6e0e02 --- /dev/null +++ b/src/Listeners/Concerns/LogsChanges.php @@ -0,0 +1,21 @@ + $attributes, 'old' => $old]; + + if (method_exists($log, 'withChanges')) { + return $log->withChanges($changes); + } + + return $log->withProperties($changes); + } +} diff --git a/src/Listeners/LogRolePermissionsUpdate.php b/src/Listeners/LogRolePermissionsUpdate.php index a0ef7c3..18d50cd 100644 --- a/src/Listeners/LogRolePermissionsUpdate.php +++ b/src/Listeners/LogRolePermissionsUpdate.php @@ -3,37 +3,24 @@ namespace Javaabu\Permissions\Listeners; use Javaabu\Permissions\Events\RolePermissionsUpdated; +use Javaabu\Permissions\Listeners\Concerns\LogsChanges; class LogRolePermissionsUpdate { - /** - * Create the event listener. - * - * @return void - */ - public function __construct() - { - // - } + use LogsChanges; - /** - * Handle the event. - * - * @param RolePermissionsUpdated $event - * @return void - */ - public function handle(RolePermissionsUpdated $event) + public function handle(RolePermissionsUpdated $event): void { $log = activity()->performedOn($event->role); - if ($user = $event->causer) { - $log->causedBy($user); + if ($event->causer) { + $log->causedBy($event->causer); } - $log->withProperties([ - 'attributes' => ['permissions' => $event->new_permissions], - 'old' => ['permissions' => $event->old_permissions] - ]) - ->log('permissions_updated'); + $this->withChanges( + $log, + ['permissions' => $event->new_permissions], + ['permissions' => $event->old_permissions], + )->log('permissions_updated'); } } diff --git a/src/Listeners/LogUserRoleUpdate.php b/src/Listeners/LogUserRoleUpdate.php index 35777f3..57431a9 100644 --- a/src/Listeners/LogUserRoleUpdate.php +++ b/src/Listeners/LogUserRoleUpdate.php @@ -3,37 +3,24 @@ namespace Javaabu\Permissions\Listeners; use Javaabu\Permissions\Events\UserRoleUpdated; +use Javaabu\Permissions\Listeners\Concerns\LogsChanges; class LogUserRoleUpdate { - /** - * Create the event listener. - * - * @return void - */ - public function __construct() - { - // - } + use LogsChanges; - /** - * Handle the event. - * - * @param UserRoleUpdated $event - * @return void - */ - public function handle(UserRoleUpdated $event) + public function handle(UserRoleUpdated $event): void { $log = activity()->performedOn($event->subject); - if ($user = $event->causer) { - $log->causedBy($user); + if ($event->causer) { + $log->causedBy($event->causer); } - $log->withProperties([ - 'attributes' => ['role' => $event->new_role], - 'old' => ['role' => $event->old_role] - ]) - ->log('role_updated'); + $this->withChanges( + $log, + ['role' => $event->new_role], + ['role' => $event->old_role], + )->log('role_updated'); } } diff --git a/src/Models/Permission.php b/src/Models/Permission.php index 043e96a..5a7f3f2 100644 --- a/src/Models/Permission.php +++ b/src/Models/Permission.php @@ -2,21 +2,62 @@ namespace Javaabu\Permissions\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Javaabu\Permissions\Database\Factories\PermissionFactory; +use Javaabu\Permissions\Registry\PermissionRegistry; use Spatie\Permission\Models\Permission as SpatiePermission; +/** + * @property int $id + * @property string $name + * @property string $guard_name + * @property string|null $description + * @property string|null $model the permission group + */ class Permission extends SpatiePermission { use HasFactory; + protected static function newFactory(): Factory + { + return PermissionFactory::new(); + } + /** - * Create a new factory instance for the role model. - * - * @return Factory + * Permissions of a guard */ - protected static function newFactory(): Factory + public function scopeForGuard(Builder $query, string $guard): Builder { - return \Database\Factories\PermissionFactory::new(); + return $query->where('guard_name', $guard); + } + + /** + * Permissions of a group + */ + public function scopeInGroup(Builder $query, string $group): Builder + { + return $query->where('model', $group); + } + + /** + * The description, or the name when there's none, translated + */ + public function getTranslatedDescriptionAttribute(): string + { + return __($this->description ?: $this->name); + } + + /** + * The translated label of the permission's group + */ + public function getGroupLabelAttribute(): string + { + if (blank($this->model)) { + return __('Other'); + } + + return __(app(PermissionRegistry::class)->label($this->model)); } } diff --git a/src/Models/Role.php b/src/Models/Role.php index 42cb5d2..5dcb328 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -2,85 +2,98 @@ namespace Javaabu\Permissions\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Javaabu\Activitylog\Traits\LogsActivity; -use Javaabu\Helpers\AdminModel\AdminModel; -use Javaabu\Helpers\AdminModel\IsAdminModel; +use Illuminate\Database\Eloquent\Model; +use Javaabu\Permissions\Database\Factories\RoleFactory; use Javaabu\Permissions\Events\RolePermissionsUpdated; -use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role as BaseRole; +use Spatie\Permission\PermissionRegistrar; -class Role extends BaseRole implements AdminModel +/** + * @property int $id + * @property string $name + * @property string $guard_name + * @property string|null $description + */ +class Role extends BaseRole { use HasFactory; - use IsAdminModel; - use LogsActivity; - /** - * The attributes that would be logged - * - * @var array - */ - protected static array $logAttributes = ['*']; - - /** - * Searchable fields - */ + /** @var array */ protected array $searchable = [ 'name', 'description', ]; + protected static function newFactory(): Factory + { + return RoleFactory::new(); + } + /** - * Create a new factory instance for the role model. - * - * @return Factory + * Roles of a guard */ - protected static function newFactory(): Factory + public function scopeForGuard(Builder $query, string $guard): Builder { - return \Database\Factories\RoleFactory::new(); + return $query->where('guard_name', $guard); } /** - * Get the admin url attribute + * Roles whose name or description contains the search term */ - public function getAdminUrlAttribute(): string + public function scopeSearch(Builder $query, ?string $search): Builder { - return route('admin.roles.show', $this); + if (blank($search)) { + return $query; + } + + return $query->where(function (Builder $query) use ($search) { + foreach ($this->searchable as $column) { + $query->orWhere($column, 'like', "%{$search}%"); + } + }); } /** - * Get name attribute - * @return string + * The description, or the name when there's none, translated */ - public function getAdminLinkNameAttribute(): string + public function getTranslatedDescriptionAttribute(): string { - return $this->description; + return __($this->description ?: $this->name); } /** - * Sync permissions + * Replace the role's permissions with the given permission ids of the role's guard, + * and dispatch RolePermissionsUpdated if they changed * - * @param array $permissions + * @param array $permission_ids */ - public function syncPermissionIds(array $permissions): void + public function syncPermissionIds(array $permission_ids, ?Model $causer = null): void { + $permission_class = app(PermissionRegistrar::class)->getPermissionClass(); + $old_permissions = $this->permissions()->pluck('name')->all(); - $new_permissions = Permission::whereIn('id', $permissions)->pluck('name')->all(); - $this->permissions()->sync($permissions); + $new_permission_ids = $permission_class::query() + ->where('guard_name', $this->guard_name) + ->whereIn('id', $permission_ids) + ->pluck('id') + ->all(); - $same_permissions = count($old_permissions) == count($new_permissions) && ! array_diff($old_permissions, $new_permissions); + $this->permissions()->sync($new_permission_ids); - if (! $same_permissions) { - $user = auth()->user(); - event(new RolePermissionsUpdated( - $old_permissions, - $new_permissions, - $this, - $user - )); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $this->unsetRelation('permissions'); + + $new_permissions = $this->permissions()->pluck('name')->all(); + + if (! array_diff($old_permissions, $new_permissions) && ! array_diff($new_permissions, $old_permissions)) { + return; } + + RolePermissionsUpdated::dispatch($old_permissions, $new_permissions, $this, $causer ?? auth()->user()); } } diff --git a/src/Models/Translatable/Role.php b/src/Models/Translatable/Role.php new file mode 100644 index 0000000..c926d9a --- /dev/null +++ b/src/Models/Translatable/Role.php @@ -0,0 +1,33 @@ + */ + protected array $translatable = ['description']; + + /** + * Keep morph types (activity log subjects, morph maps) pointing at the base role + */ + public function getMorphClass(): string + { + $morph_class = parent::getMorphClass(); + + return $morph_class === static::class + ? (new BaseRole)->getMorphClass() + : $morph_class; + } +} diff --git a/src/Permissions.php b/src/Permissions.php index e464093..b15d436 100644 --- a/src/Permissions.php +++ b/src/Permissions.php @@ -2,24 +2,67 @@ namespace Javaabu\Permissions; -class Permissions +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Facade; +use Javaabu\Permissions\Registry\PermissionGroup; +use Javaabu\Permissions\Registry\PermissionRegistry; +use Javaabu\Permissions\Registry\RoleDefinition; +use Javaabu\Permissions\Sync\PermissionSyncer; +use Javaabu\Permissions\Sync\SyncResult; + +/** + * @method static PermissionGroup register(string $group, array $permissions = [], ?string $label = null) + * @method static PermissionGroup resource(string $group, ?string $noun = null, array $actions = PermissionRegistry::DEFAULT_ACTIONS, ?string $label = null) + * @method static RoleDefinition role(string $name, ?string $description = null, string|array $permissions = [], array|string|null $guards = null) + * @method static PermissionRegistry policy(string $model, string $policy) + * @method static PermissionGroup|null group(string $name) + * @method static array groups() + * @method static array permissions(?string $guard = null) + * @method static bool has(string $permission) + * @method static string|null description(string $permission) + * @method static PermissionGroup|null groupOf(string $permission) + * @method static string label(string $group) + * @method static array roles() + * @method static array policies() + * @method static void applyPolicies(?\Illuminate\Contracts\Auth\Access\Gate $gate = null) + * @method static array defaultGuards() + * @method static array guardsFor(PermissionGroup|RoleDefinition $definition) + * @method static array allGuards() + * @method static string guardFor(Model $user) + * @method static bool check(string|array $permissions, ?string $guard = null) + * @method static void flush() + * + * @see PermissionRegistry + */ +class Permissions extends Facade { + public static bool $runs_migrations = true; + /** - * Indicates if migrations will be run. - * - * @var bool + * Stop the package from loading its migrations, e.g. after publishing them */ - public static $runsMigrations = true; + public static function ignoreMigrations(): void + { + static::$runs_migrations = false; + } /** - * Configure to not register its migrations. + * Create or update the registered permissions and roles in the database * - * @return static + * @param array|null $guards */ - public static function ignoreMigrations() + public static function sync(?array $guards = null, bool $prune = false): SyncResult { - static::$runsMigrations = false; + return static::$app->make(PermissionSyncer::class)->sync($guards, $prune); + } - return new static; + public static function permissionName(string $action, string $resource): string + { + return PermissionRegistry::permissionName($action, $resource); + } + + protected static function getFacadeAccessor(): string + { + return PermissionRegistry::class; } } diff --git a/src/PermissionsServiceProvider.php b/src/PermissionsServiceProvider.php index 212ccc1..e6df77f 100644 --- a/src/PermissionsServiceProvider.php +++ b/src/PermissionsServiceProvider.php @@ -3,61 +3,153 @@ namespace Javaabu\Permissions; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; -use Javaabu\Permissions\Providers\EventServiceProvider; +use Javaabu\Manifest\Concerns\RegistersAssets; +use Javaabu\Manifest\Package; +use Javaabu\Permissions\Commands\SyncPermissionsCommand; +use Javaabu\Permissions\Events\RolePermissionsUpdated; +use Javaabu\Permissions\Events\UserRoleUpdated; +use Javaabu\Permissions\Listeners\LogRolePermissionsUpdate; +use Javaabu\Permissions\Listeners\LogUserRoleUpdate; +use Javaabu\Permissions\Policies\RolePolicy; +use Javaabu\Permissions\Registry\PermissionRegistry; +use Javaabu\Permissions\Routing\RoleRoutes; +use Spatie\Permission\PermissionRegistrar; class PermissionsServiceProvider extends ServiceProvider { - /** - * Bootstrap the application services. - */ + use RegistersAssets; + + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../config/permission.php', 'permission'); + $this->mergeConfigFrom(__DIR__ . '/../config/permissions.php', 'permissions'); + + $this->app->singleton(PermissionRegistry::class); + } + public function boot(): void { - // declare publishes if ($this->app->runningInConsole()) { $this->registerMigrations(); + $this->registerPublishing(); + + $this->commands([ + SyncPermissionsCommand::class, + ]); + } - $this->publishes([ - __DIR__.'/../database/migrations' => database_path('migrations'), - ], 'permissions-migrations'); + $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'permissions'); - $this->publishes([ - __DIR__ . '/../config/permission.php' => config_path('permission.php'), - ], 'permissions-config'); + $this->registerFrontendAssets(); + $this->registerRouteMacros(); + $this->registerDefaultPermissions(); + + $this->registerBladeDirectives(); + $this->registerActivityLogging(); + + $this->app->booted(function () { + $this->app->make(PermissionRegistry::class)->applyPolicies(); + }); + } + + protected function registerMigrations(): void + { + if (Permissions::$runs_migrations) { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); } + } + + protected function registerPublishing(): void + { + $this->publishes([ + __DIR__ . '/../database/migrations' => database_path('migrations'), + ], 'permissions-migrations'); + + $this->publishes([ + __DIR__ . '/../config/permissions.php' => config_path('permissions.php'), + ], 'permissions-config'); - /** - * Returns true if the user has any of the permissions - */ - Blade::if('anypermission', function ($permissions) { - if ($user = auth()->user()) { - return $user->anyPermission($permissions); - } + $this->publishes([ + __DIR__ . '/../config/permission.php' => config_path('permission.php'), + ], 'permissions-spatie-config'); - return false; + $this->publishes([ + __DIR__ . '/../resources/views' => resource_path('views/vendor/permissions'), + ], 'permissions-views'); + } + + /** + * The Inertia pages (permissions::Roles/Index, …), the components apps can import as + * `@javaabu/permissions`, and the script the Blade pages load + */ + protected function registerFrontendAssets(): void + { + $this->registerAssets('permissions', __DIR__ . '/../resources', function (Package $package) { + $package + ->script('roles', 'js/roles.ts') + ->js() + ->pages() + // roles.ts (the Blade script) imports nothing; only the Inertia pages and the + // components apps import from @javaabu/permissions need these, so Blade apps + // aren't asked to install them. Onu is a peer of @javaabu/forms. + ->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'); + }); + } + + protected function registerRouteMacros(): void + { + Route::macro('roles', function (array $options = []) { + RoleRoutes::register($this, $options); }); } /** - * Register migration files. - * - * @return void + * The package's own permissions (view_roles, edit_roles, delete_roles) and role policy */ - protected function registerMigrations() + protected function registerDefaultPermissions(): void { - if (Permissions::$runsMigrations) { - $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + if (! config('permissions.register_defaults', true)) { + return; } + + $registry = $this->app->make(PermissionRegistry::class); + + $registry->resource('roles', 'roles', ['view', 'edit', 'delete'], label: 'Roles'); + $registry->policy($this->app->make(PermissionRegistrar::class)->getRoleClass(), RolePolicy::class); + } + + protected function registerBladeDirectives(): void + { + Blade::if('anypermission', function (string|array $permissions, ?string $guard = null) { + return $this->app->make(PermissionRegistry::class)->check($permissions, $guard); + }); } /** - * Register the application services. + * Log role and permission changes when spatie/laravel-activitylog is installed */ - public function register(): void + protected function registerActivityLogging(): void { - // merge package config with user defined config - $this->mergeConfigFrom(__DIR__ . '/../config/permission.php', 'permission'); + if (! config('permissions.log_activity', true)) { + return; + } + + if (! function_exists('activity')) { + return; + } - $this->app->register(EventServiceProvider::class); + Event::listen(UserRoleUpdated::class, LogUserRoleUpdate::class); + Event::listen(RolePermissionsUpdated::class, LogRolePermissionsUpdate::class); } } diff --git a/src/Policies/ResourcePolicy.php b/src/Policies/ResourcePolicy.php new file mode 100644 index 0000000..0f19bf3 --- /dev/null +++ b/src/Policies/ResourcePolicy.php @@ -0,0 +1,156 @@ + 'approve_exports']`. A list of + * permissions means the user needs any of them. + */ +abstract class ResourcePolicy +{ + /** The resource part of the permission names, e.g. `exports` for `view_exports` */ + protected string $resource; + + /** Check permissions of this guard instead of going through the gate */ + protected ?string $guard = null; + + /** Whether update and delete distinguish the user's own models from others' */ + protected bool $owned = false; + + /** @var array> ability => permission(s) */ + protected array $abilities = []; + + public function viewAny(Model $user): bool + { + return $this->allows($user, $this->permission('view')); + } + + public function view(Model $user, Model $model): bool + { + if ($this->allows($user, $this->permission('view'))) { + return true; + } + + return $this->owned && $this->update($user, $model); + } + + public function create(Model $user): bool + { + return $this->allows($user, $this->permission('edit')); + } + + public function update(Model $user, Model $model): bool + { + return $this->allowsOwned($user, $model, 'edit'); + } + + public function delete(Model $user, Model $model): bool + { + return $this->allowsOwned($user, $model, 'delete'); + } + + public function restore(Model $user, Model $model): bool + { + return $this->delete($user, $model) || $this->forceDelete($user, $model); + } + + public function forceDelete(Model $user, Model $model): bool + { + return $this->allows($user, $this->permission('force_delete')); + } + + public function viewTrash(Model $user): bool + { + return $this->allows($user, $this->permission('delete'), $this->permission('force_delete')); + } + + /** + * Abilities from `$abilities`. Unknown abilities are denied. + * + * @param array $arguments + */ + public function __call(string $ability, array $arguments): bool + { + $user = $arguments[0] ?? null; + + if (! $user instanceof Model) { + return false; + } + + if (! array_key_exists($ability, $this->abilities)) { + return false; + } + + return $this->allows($user, ...Arr::wrap($this->abilities[$ability])); + } + + /** + * The permission name for an action on this policy's resource + */ + public function permission(string $action): string + { + return PermissionRegistry::permissionName($action, $this->resource); + } + + /** + * Whether the user has any of the permissions + */ + protected function allows(Model $user, string ...$permissions): bool + { + foreach ($permissions as $permission) { + $allowed = $this->guard !== null && method_exists($user, 'checkPermissionTo') + ? $user->checkPermissionTo($permission, $this->guard) + : $user->can($permission); + + if ($allowed) { + return true; + } + } + + return false; + } + + protected function allowsOwned(Model $user, Model $model, string $action): bool + { + if (! $this->owned) { + return $this->allows($user, $this->permission($action)); + } + + if ($this->allows($user, $this->permission("{$action}_others"))) { + return true; + } + + return $this->allows($user, $this->permission($action)) && $this->isOwner($user, $model); + } + + /** + * Whether the user owns the model. Calls `$model->isOwnedBy($user)` when the model has it; + * override for anything else. + */ + protected function isOwner(Model $user, Model $model): bool + { + if (method_exists($model, 'isOwnedBy')) { + return (bool) $model->isOwnedBy($user); + } + + return false; + } +} diff --git a/src/Policies/RolePolicy.php b/src/Policies/RolePolicy.php new file mode 100644 index 0000000..23a6e78 --- /dev/null +++ b/src/Policies/RolePolicy.php @@ -0,0 +1,31 @@ + [ - LogUserRoleUpdate::class, - ], - - RolePermissionsUpdated::class => [ - LogRolePermissionsUpdate::class, - ] - ]; - - /** - * Register any events for your application. - * - * @return void - */ - public function boot() - { - parent::boot(); - } -} diff --git a/src/Registry/PermissionGroup.php b/src/Registry/PermissionGroup.php new file mode 100644 index 0000000..c47219e --- /dev/null +++ b/src/Registry/PermissionGroup.php @@ -0,0 +1,116 @@ + */ + protected array $permissions = []; + + protected ?string $label = null; + + /** @var array|null */ + protected ?array $guards = null; + + /** @var array|null> */ + protected array $grants = []; + + public function __construct( + public readonly string $name, + ) {} + + /** + * Add permissions to the group, as `name => description` pairs or a list of names + * + * @param array $permissions + */ + public function add(array $permissions): static + { + foreach ($permissions as $name => $description) { + if (is_int($name)) { + $name = $description; + $description = Str::ucfirst(str_replace('_', ' ', $description)); + } + + $this->permissions[$name] = $description; + } + + return $this; + } + + /** + * Set the label shown for the group, an English string used as a translation key + */ + public function label(string $label): static + { + $this->label = $label; + + return $this; + } + + /** + * Limit the group to the given guards instead of the configured default guards + * + * @param array|string $guards + */ + public function guards(array|string $guards): static + { + $this->guards = Arr::wrap($guards); + + return $this; + } + + /** + * Give roles, if they exist, the permissions of this group when syncing + * + * @param array|string $roles + * @param array|null $only permission names; all the group's permissions when null + */ + public function grantTo(array|string $roles, ?array $only = null): static + { + foreach (Arr::wrap($roles) as $role) { + $this->grants[$role] = $only; + } + + return $this; + } + + /** @return array */ + public function getPermissions(): array + { + return $this->permissions; + } + + public function getLabel(): string + { + return $this->label ?? Str::headline($this->name); + } + + /** @return array|null */ + public function getGuards(): ?array + { + return $this->guards; + } + + /** @return array> */ + public function getGrants(): array + { + $grants = []; + + foreach ($this->grants as $role => $only) { + $grants[$role] = $only === null + ? array_keys($this->permissions) + : array_values(array_intersect($only, array_keys($this->permissions))); + } + + return $grants; + } + + public function has(string $permission): bool + { + return array_key_exists($permission, $this->permissions); + } +} diff --git a/src/Registry/PermissionRegistry.php b/src/Registry/PermissionRegistry.php new file mode 100644 index 0000000..0a22f92 --- /dev/null +++ b/src/Registry/PermissionRegistry.php @@ -0,0 +1,345 @@ + 'View :resource', + 'edit' => 'Edit :resource', + 'edit_others' => 'Edit all :resource', + 'delete' => 'Delete :resource', + 'delete_others' => 'Delete all :resource', + 'force_delete' => 'Permanently delete :resource', + 'approve' => 'Approve :resource', + 'publish' => 'Publish :resource', + 'import' => 'Import :resource', + 'export' => 'Export :resource', + ]; + + /** @var array */ + protected array $groups = []; + + /** @var array */ + protected array $roles = []; + + /** @var array */ + protected array $policies = []; + + protected bool $policies_applied = false; + + public function __construct( + protected Application $app, + ) {} + + /** + * Register permissions under a group, such as a model or a package + * + * Calling it again with the same group adds to it. + * + * @param array $permissions `name => English description` + */ + public function register(string $group, array $permissions = [], ?string $label = null): PermissionGroup + { + $permission_group = $this->groups[$group] ??= new PermissionGroup($group); + + $permission_group->add($permissions); + + if ($label !== null) { + $permission_group->label($label); + } + + return $permission_group; + } + + /** + * Register the standard permissions for a resource: `view_exports`, `edit_exports`, … + * + * @param string|null $noun the plural noun used in the descriptions, e.g. `exports` in "View exports" + * @param array $actions + */ + public function resource( + string $group, + ?string $noun = null, + array $actions = self::DEFAULT_ACTIONS, + ?string $label = null, + ): PermissionGroup { + $noun ??= str_replace('_', ' ', $group); + $permissions = []; + + foreach ($actions as $action) { + $template = self::ACTION_DESCRIPTIONS[$action] + ?? Str::ucfirst(str_replace('_', ' ', $action)) . ' :resource'; + + $permissions[static::permissionName($action, $group)] = str_replace(':resource', $noun, $template); + } + + return $this->register($group, $permissions, $label); + } + + /** + * Define a role the sync creates, and which permissions it gets + * + * Roles in `config('permissions.roles')` override roles defined here. + * + * @param string|array $permissions `*` for every permission, or names and `Str::is()` patterns + * @param array|string|null $guards + */ + public function role( + string $name, + ?string $description = null, + string|array $permissions = [], + array|string|null $guards = null, + ): RoleDefinition { + return $this->roles[$name] = new RoleDefinition( + $name, + $description, + $permissions, + $guards === null ? null : Arr::wrap($guards), + ); + } + + /** + * Register the default policy for a model + * + * The app overrides it with `config('permissions.policies')` or `Gate::policy()`. + * + * @param class-string $model + * @param class-string $policy + */ + public function policy(string $model, string $policy): static + { + $this->policies[$model] = $policy; + + if ($this->policies_applied) { + $this->applyPolicy($this->app->make(Gate::class), $model, $policy); + } + + return $this; + } + + public function group(string $name): ?PermissionGroup + { + return $this->groups[$name] ?? null; + } + + /** @return array */ + public function groups(): array + { + return $this->groups; + } + + /** + * The registered permissions, as `name => description` + * + * @return array + */ + public function permissions(?string $guard = null): array + { + $permissions = []; + + foreach ($this->groups as $group) { + if ($guard !== null && ! in_array($guard, $this->guardsFor($group), true)) { + continue; + } + + $permissions = array_merge($permissions, $group->getPermissions()); + } + + return $permissions; + } + + public function has(string $permission): bool + { + return array_key_exists($permission, $this->permissions()); + } + + public function description(string $permission): ?string + { + return $this->permissions()[$permission] ?? null; + } + + public function groupOf(string $permission): ?PermissionGroup + { + foreach ($this->groups as $group) { + if ($group->has($permission)) { + return $group; + } + } + + return null; + } + + /** + * The label for a group, untranslated, falling back to a headline of its name + */ + public function label(string $group): string + { + return $this->group($group)?->getLabel() ?? Str::headline($group); + } + + /** + * Roles defined in code, overridden by `config('permissions.roles')` + * + * @return array + */ + public function roles(): array + { + $roles = $this->roles; + + foreach ($this->app['config']->get('permissions.roles', []) as $name => $config) { + $roles[$name] = RoleDefinition::fromConfig($name, $config ?? []); + } + + return $roles; + } + + /** + * Package policies, overridden by `config('permissions.policies')` + * + * @return array + */ + public function policies(): array + { + return array_merge($this->policies, $this->configuredPolicies()); + } + + /** + * Add the policies to the gate. Config overrides always apply; + * package defaults only where the app hasn't defined a policy. + */ + public function applyPolicies(?Gate $gate = null): void + { + $gate ??= $this->app->make(Gate::class); + + foreach ($this->policies() as $model => $policy) { + $this->applyPolicy($gate, $model, $policy); + } + + $this->policies_applied = true; + } + + /** + * The guards permissions are created for when a group doesn't name its own + * + * @return array + */ + public function defaultGuards(): array + { + $guards = Arr::wrap($this->app['config']->get('permissions.guards')); + + return $guards ?: [$this->app['config']->get('auth.defaults.guard', 'web')]; + } + + /** @return array */ + public function guardsFor(PermissionGroup|RoleDefinition $definition): array + { + $guards = $definition instanceof PermissionGroup + ? $definition->getGuards() + : $definition->guards; + + return $guards ?? $this->defaultGuards(); + } + + /** + * Every guard the registered permissions and roles use + * + * @return array + */ + public function allGuards(): array + { + $guards = $this->defaultGuards(); + + foreach ([...array_values($this->groups), ...array_values($this->roles())] as $definition) { + $guards = array_merge($guards, $this->guardsFor($definition)); + } + + return array_values(array_unique($guards)); + } + + /** + * The guard spatie/laravel-permission uses for a user model + */ + public function guardFor(Model $user): string + { + return Guard::getDefaultName($user); + } + + /** + * Whether the user signed in on the guard has any of the permissions + * + * @param string|array $permissions + */ + public function check(string|array $permissions, ?string $guard = null): bool + { + $user = $this->app['auth']->guard($guard)->user(); + + if (! $user) { + return false; + } + + if (method_exists($user, 'anyPermission')) { + return $user->anyPermission($permissions, $guard); + } + + foreach (Arr::wrap($permissions) as $permission) { + if ($user->can($permission)) { + return true; + } + } + + return false; + } + + /** + * The conventional permission name for an action on a resource: `edit` + `exports` = `edit_exports` + */ + public static function permissionName(string $action, string $resource): string + { + return "{$action}_{$resource}"; + } + + /** + * Forget everything registered. Meant for tests. + */ + public function flush(): void + { + $this->groups = []; + $this->roles = []; + $this->policies = []; + } + + /** @return array */ + protected function configuredPolicies(): array + { + return $this->app['config']->get('permissions.policies', []); + } + + protected function applyPolicy(Gate $gate, string $model, string $policy): void + { + $configured = $this->configuredPolicies(); + + if (array_key_exists($model, $configured)) { + $gate->policy($model, $configured[$model]); + + return; + } + + if (array_key_exists($model, $gate->policies())) { + return; + } + + $gate->policy($model, $policy); + } +} diff --git a/src/Registry/RoleDefinition.php b/src/Registry/RoleDefinition.php new file mode 100644 index 0000000..10e15da --- /dev/null +++ b/src/Registry/RoleDefinition.php @@ -0,0 +1,67 @@ + $permissions `*` for every permission, or names and `Str::is()` patterns + * @param array|null $guards + */ + public function __construct( + public readonly string $name, + public ?string $description = null, + public string|array $permissions = [], + public ?array $guards = null, + ) {} + + /** + * @param array{ + * description?: string|null, + * permissions?: string|array, + * guards?: array|string|null + * } $config + */ + public static function fromConfig(string $name, array $config): static + { + return new static( + $name, + $config['description'] ?? null, + $config['permissions'] ?? [], + isset($config['guards']) ? Arr::wrap($config['guards']) : null, + ); + } + + public function getDescription(): string + { + return $this->description ?? Str::headline($this->name); + } + + public function grantsEverything(): bool + { + return $this->permissions === '*' || $this->permissions === ['*']; + } + + /** + * Pick the permissions this role should get from the given names + * + * @param array $available + * @return array + */ + public function resolvePermissions(array $available): array + { + if ($this->grantsEverything()) { + return array_values($available); + } + + $patterns = Arr::wrap($this->permissions); + + return array_values(array_filter( + $available, + fn (string $permission) => Str::is($patterns, $permission), + )); + } +} diff --git a/src/Routing/RoleRoutes.php b/src/Routing/RoleRoutes.php new file mode 100644 index 0000000..cd34538 --- /dev/null +++ b/src/Routing/RoleRoutes.php @@ -0,0 +1,79 @@ +|null, + * controller?: class-string|null + * } $options + */ + public static function register(Router $router, array $options = []): void + { + $guard = $options['guard'] ?? app(PermissionRegistry::class)->defaultGuards()[0]; + $prefix = $options['prefix'] ?? 'roles'; + $name = rtrim($options['name'] ?? 'roles', '.'); + $middleware = $options['middleware'] ?? ["auth:{$guard}"]; + $controller = $options['controller'] ?? config('permissions.controllers.roles', RolesController::class); + + $router->group([ + 'prefix' => $prefix, + 'as' => "{$name}.", + 'middleware' => $middleware, + self::GUARD_ACTION => $guard, + ], function (Router $router) use ($controller) { + $router->get('/', [$controller, 'index'])->name('index'); + $router->get('/create', [$controller, 'create'])->name('create'); + $router->post('/', [$controller, 'store'])->name('store'); + $router->get('/{role}', [$controller, 'show'])->name('show'); + $router->get('/{role}/edit', [$controller, 'edit'])->name('edit'); + $router->match(['put', 'patch'], '/{role}', [$controller, 'update'])->name('update'); + $router->delete('/{role}', [$controller, 'destroy'])->name('destroy'); + }); + } + + /** + * The guard the current route manages roles for + */ + public static function guard(Request $request): string + { + return $request->route()?->getAction(self::GUARD_ACTION) + ?? app(PermissionRegistry::class)->defaultGuards()[0]; + } + + /** + * Whether the current route is one of the role routes + */ + public static function isRoleRoute(Request $request): bool + { + return $request->route()?->getAction(self::GUARD_ACTION) !== null; + } + + /** + * The route name prefix of the current route, e.g. `admin.roles` + */ + public static function namePrefix(Request $request): string + { + $name = (string) $request->route()?->getName(); + + return str_contains($name, '.') + ? substr($name, 0, strrpos($name, '.')) + : 'roles'; + } +} diff --git a/src/Support/PermissionGroups.php b/src/Support/PermissionGroups.php new file mode 100644 index 0000000..3e956ec --- /dev/null +++ b/src/Support/PermissionGroups.php @@ -0,0 +1,102 @@ + + * }> + */ + public function forGuard(string $guard, ?Model $role = null): array + { + $permissions = $this->permission_registrar->getPermissionClass()::query() + ->where('guard_name', $guard) + ->orderBy('name') + ->get(); + + $granted = $role + ? $role->permissions()->pluck('id')->all() + : []; + + return $this->group($permissions, $granted); + } + + /** + * Only the permissions the role has, grouped + * + * @return array + * }> + */ + public function forRole(Model $role): array + { + $permissions = $role->permissions()->orderBy('name')->get(); + + return $this->group($permissions, $permissions->pluck('id')->all()); + } + + /** + * Groups in registration order, then unregistered groups alphabetically, then ungrouped permissions + * + * @param array $granted + * @return array> + */ + protected function group(Collection $permissions, array $granted): array + { + $order = array_flip(array_keys($this->registry->groups())); + + return $permissions + ->groupBy(fn (Model $permission) => (string) $permission->model) + ->sortBy(fn ($items, string $group) => match (true) { + $group === '' => [2, ''], + isset($order[$group]) => [0, sprintf('%05d', $order[$group])], + default => [1, $group], + }) + ->map(fn (Collection $items, string $group) => [ + 'name' => $group, + 'label' => $group === '' ? __('Other') : __($this->registry->label($group)), + 'permissions' => $this->sortWithinGroup($items, $group)->map(fn (Model $permission) => [ + 'id' => $permission->getKey(), + 'name' => $permission->name, + 'description' => __($permission->description ?: $permission->name), + 'granted' => in_array($permission->getKey(), $granted), + ])->values()->all(), + ]) + ->values() + ->all(); + } + + /** + * Registered permissions in the order they were registered, then the rest by name + */ + protected function sortWithinGroup(Collection $items, string $group): Collection + { + $order = array_flip(array_keys($this->registry->group($group)?->getPermissions() ?? [])); + + return $items->sortBy(fn (Model $permission) => [ + $order[$permission->name] ?? PHP_INT_MAX, + $permission->name, + ]); + } +} diff --git a/src/Support/Translations.php b/src/Support/Translations.php new file mode 100644 index 0000000..58e4c34 --- /dev/null +++ b/src/Support/Translations.php @@ -0,0 +1,94 @@ +getRoleClass(), 'description'); + } + + /** + * Expand the description rules into one rule per locale when the role is translated + * + * @param array $rules + * @return array + */ + public static function rules(?Model $role, array $rules): array + { + if (! static::supportsRole($role)) { + return $rules; + } + + return Translatable::rules($role ?? app(PermissionRegistrar::class)->getRoleClass(), $rules); + } + + /** + * The description in every locale, keyed by input name: `['description_en' => …, 'description_dv' => …]` + * + * @return array + */ + public static function values(?Model $role): array + { + if (! $role || ! static::supportsRole($role)) { + return []; + } + + return Translatable::values($role, static::FIELDS); + } + + /** + * What the role form needs to render one description input per locale, or null when + * descriptions aren't translated + * + * @return array{values: array, inputs: array>>}|null + */ + public static function form(?Model $role): ?array + { + if (! static::supportsRole($role)) { + return null; + } + + $model = $role ?? app(PermissionRegistrar::class)->getRoleClass(); + + return [ + 'values' => static::values($role), + 'inputs' => [ + 'description' => Translatable::inputs('description', $model) + ->map(fn (LocaleInput $input) => [ + ...$input->toArray(), + 'label' => $input->label(__('Description')), + ]) + ->values() + ->all(), + ], + ]; + } +} diff --git a/src/Sync/PermissionSyncer.php b/src/Sync/PermissionSyncer.php new file mode 100644 index 0000000..93dc945 --- /dev/null +++ b/src/Sync/PermissionSyncer.php @@ -0,0 +1,174 @@ +|null $only_guards limit the sync to these guards + * @param bool $prune delete permissions in registered groups that are no longer registered + */ + public function sync(?array $only_guards = null, bool $prune = false): SyncResult + { + $result = new SyncResult; + + $this->permission_registrar->forgetCachedPermissions(); + + $this->syncPermissions($result, $only_guards, $prune); + $this->syncRoles($result, $only_guards); + $this->syncGrants($result, $only_guards); + + $this->permission_registrar->forgetCachedPermissions(); + + return $result; + } + + protected function syncPermissions(SyncResult $result, ?array $only_guards, bool $prune): void + { + $permission_class = $this->permissionClass(); + + foreach ($this->registry->groups() as $group) { + foreach ($this->filterGuards($this->registry->guardsFor($group), $only_guards) as $guard) { + foreach ($group->getPermissions() as $name => $description) { + $permission = $permission_class::query()->firstOrNew([ + 'name' => $name, + 'guard_name' => $guard, + ]); + + $exists = $permission->exists; + + $permission->forceFill([ + 'description' => $description, + 'model' => $group->name, + ]); + + if (! $permission->isDirty()) { + continue; + } + + $permission->save(); + + $exists + ? $result->updated_permissions[] = "{$guard}:{$name}" + : $result->created_permissions[] = "{$guard}:{$name}"; + } + + if (! $prune) { + continue; + } + + $stale = $permission_class::query() + ->where('guard_name', $guard) + ->where('model', $group->name) + ->whereNotIn('name', array_keys($group->getPermissions())) + ->get(); + + foreach ($stale as $permission) { + $result->pruned_permissions[] = "{$guard}:{$permission->name}"; + + $permission->delete(); + } + } + } + } + + protected function syncRoles(SyncResult $result, ?array $only_guards): void + { + $role_class = $this->roleClass(); + $permission_class = $this->permissionClass(); + + foreach ($this->registry->roles() as $definition) { + foreach ($this->filterGuards($this->registry->guardsFor($definition), $only_guards) as $guard) { + $role = $role_class::query()->firstOrNew([ + 'name' => $definition->name, + 'guard_name' => $guard, + ]); + + $exists = $role->exists; + + $role->forceFill(['description' => $definition->getDescription()]); + + if ($role->isDirty()) { + $role->save(); + + $exists + ? $result->updated_roles[] = "{$guard}:{$definition->name}" + : $result->created_roles[] = "{$guard}:{$definition->name}"; + } + + $available = $permission_class::query() + ->where('guard_name', $guard) + ->pluck('name') + ->all(); + + $permissions = $definition->resolvePermissions($available); + + $definition->grantsEverything() + ? $role->syncPermissions($permissions) + : $role->givePermissionTo($permissions); + } + } + } + + protected function syncGrants(SyncResult $result, ?array $only_guards): void + { + $role_class = $this->roleClass(); + + foreach ($this->registry->groups() as $group) { + foreach ($group->getGrants() as $role_name => $permissions) { + foreach ($this->filterGuards($this->registry->guardsFor($group), $only_guards) as $guard) { + $role = $role_class::query() + ->where('name', $role_name) + ->where('guard_name', $guard) + ->first(); + + if (! $role) { + $result->skipped_grants[] = "{$guard}:{$role_name}"; + + continue; + } + + $role->givePermissionTo($permissions); + } + } + } + } + + /** + * @param array $guards + * @param array|null $only_guards + * @return array + */ + protected function filterGuards(array $guards, ?array $only_guards): array + { + if (! $only_guards) { + return $guards; + } + + return array_values(array_intersect($guards, $only_guards)); + } + + /** @return class-string */ + protected function permissionClass(): string + { + return $this->permission_registrar->getPermissionClass(); + } + + /** @return class-string */ + protected function roleClass(): string + { + return $this->permission_registrar->getRoleClass(); + } +} diff --git a/src/Sync/SyncResult.php b/src/Sync/SyncResult.php new file mode 100644 index 0000000..d133f72 --- /dev/null +++ b/src/Sync/SyncResult.php @@ -0,0 +1,24 @@ + `guard:name` */ + public array $created_permissions = []; + + /** @var array `guard:name` */ + public array $updated_permissions = []; + + /** @var array `guard:name` */ + public array $pruned_permissions = []; + + /** @var array `guard:name` */ + public array $created_roles = []; + + /** @var array `guard:name` */ + public array $updated_roles = []; + + /** @var array `guard:role`, for grants to roles that don't exist */ + public array $skipped_grants = []; +} diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index 3567342..cdec768 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -2,28 +2,33 @@ namespace Javaabu\Permissions\Traits; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Javaabu\Permissions\Events\UserRoleUpdated; -use Javaabu\Permissions\Models\Role; +use Spatie\Permission\PermissionRegistrar; +use Spatie\Permission\Traits\HasRoles as SpatieHasRoles; trait HasRoles { - use \Spatie\Permission\Traits\HasRoles; + use SpatieHasRoles; /** - * Check if the user has any of the following permissions + * Whether the model has any of the permissions * - * @param array|string $permissions + * Without a guard, this goes through the gate, so `Gate::before()` callbacks apply. + * With a guard, only permissions of that guard count. + * + * @param string|array $permissions */ - public function anyPermission($permissions): bool + public function anyPermission(string|array $permissions, ?string $guard = null): bool { - if (! is_array($permissions)) { - $permissions = [$permissions]; - } + foreach (Arr::wrap($permissions) as $permission) { + $allowed = $guard === null + ? $this->can($permission) + : $this->checkPermissionTo($permission, $guard); - foreach ($permissions as $permission) { - if ($this->can($permission)) { + if ($allowed) { return true; } } @@ -32,80 +37,88 @@ public function anyPermission($permissions): bool } /** - * Update the role + * Change the model's roles, if the causer may, and dispatch UserRoleUpdated * - * @param $role - * @param bool $new whether creating user for the first time + * The causer may when it passes the `updateRole` ability for this model class. + * When it doesn't and `$new` is true, the model gets the default role instead + * (`$default_role`, or `config('permissions.default_role')`). Roles that don't + * exist for the model's guard are ignored; if none of the given roles exist, + * nothing changes. A causer that may update roles can pass `[]` to remove them all. + * + * @param string|array $role + * @param bool $new whether the model is being created + * @param Model|null $causer defaults to the signed-in user */ - public function updateRole($role, bool $new = false) + public function updateRole(string|array $role, bool $new = false, ?Model $causer = null, ?string $default_role = null): void { - $user = auth()->user(); - if (! $user instanceof Model) { + $causer ??= auth()->user(); + + if (! $causer instanceof Model) { return; } - $role = Arr::wrap($role); + $can_update_role = $causer->can('updateRole', static::class); + + if (! $can_update_role && ! $new) { + return; + } - // update role only if it's a new user or the user can update the role - if ($user->can('updateRole', self::class) || $new) { - $old_role = $this->roles()->pluck('name')->all(); - $new_role = $user->can('updateRole', self::class) ? $role : [get_setting('default_role')]; + $new_role = $can_update_role + ? Arr::wrap($role) + : array_filter([$default_role ?? config('permissions.default_role')]); - // remove fake roles - if ($new_role) { - $correct_role_names = Role::whereIn('name', $new_role)->pluck('name')->all(); - $correct_roles = array_intersect($new_role, $correct_role_names); + if ($new_role) { + $role_class = app(PermissionRegistrar::class)->getRoleClass(); - // no correct rules - if (! $correct_roles) { - return; - } + $existing_roles = $role_class::query() + ->where('guard_name', $this->getDefaultGuardName()) + ->whereIn('name', $new_role) + ->pluck('name') + ->all(); - $new_role = $correct_roles; + $new_role = array_values(array_intersect($new_role, $existing_roles)); + + if (! $new_role) { + return; } + } + + if (! $new_role && ! $can_update_role) { + return; + } - $this->syncRoles($new_role); + $old_role = $this->roles()->pluck('name')->all(); - $same_roles = count($old_role) == count($new_role) && ! array_diff($old_role, $new_role); + $this->syncRoles($new_role); - // log the role update - if (! $same_roles) { - event(new UserRoleUpdated($old_role, $new_role, $this, $user)); - } + if (! array_diff($old_role, $new_role) && ! array_diff($new_role, $old_role)) { + return; } + + UserRoleUpdated::dispatch($old_role, $new_role, $this, $causer); } /** - * Get all users with a specific permission - * - * @param $query - * @param $permissions array|string - * @return mixed + * The model's first role */ - public function scopePermission($query, $permissions): mixed + public function getRoleAttribute(): ?Model { - if (! is_array($permissions)) { - $permissions = [$permissions]; - } - - $query->whereHas('roles', function ($query) use ($permissions) { - foreach ($permissions as $permission) { - $query->whereHas('permissions', function ($query) use ($permission) { - $query->whereName($permission); - }); - } - }); - - return $query; + return $this->roles->first(); } /** - * Get role attribute + * Models that have the permission through any of their roles, on any guard + * + * Unlike spatie's `permission()` scope, this ignores direct permissions. * - * @return Role|null + * @param string|array $permissions all are required */ - public function getRoleAttribute(): ?Role + public function scopeWhereRoleHasPermissions(Builder $query, string|array $permissions): Builder { - return $this->roles->first(); + return $query->whereHas('roles', function (Builder $query) use ($permissions) { + foreach (Arr::wrap($permissions) as $permission) { + $query->whereHas('permissions', fn (Builder $query) => $query->where('name', $permission)); + } + }); } } diff --git a/src/Views/ViewRenderer.php b/src/Views/ViewRenderer.php new file mode 100644 index 0000000..f91a0ba --- /dev/null +++ b/src/Views/ViewRenderer.php @@ -0,0 +1,64 @@ + $data + */ + public function render(string $page, array $data = []): View|Responsable + { + if ($this->isInertia()) { + return $this->renderInertia($page, $data); + } + + return view("permissions::{$page}", $data); + } + + public function stack(): string + { + return config('permissions.views', self::BLADE); + } + + public function isInertia(): bool + { + return $this->stack() === self::INERTIA; + } + + /** + * The Inertia page name for a page: `roles.index` → `permissions::Roles/Index` + */ + public function inertiaPage(string $page): string + { + $segments = array_map(fn (string $segment) => str($segment)->studly()->toString(), explode('.', $page)); + + return 'permissions::' . implode('/', $segments); + } + + /** + * @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 permissions.views to blade.'); + } + + return Inertia::render($this->inertiaPage($page), $data); + } +} diff --git a/tests/Feature/ActivityLogDisabledTest.php b/tests/Feature/ActivityLogDisabledTest.php new file mode 100644 index 0000000..a49f6b8 --- /dev/null +++ b/tests/Feature/ActivityLogDisabledTest.php @@ -0,0 +1,34 @@ +set('permissions.log_activity', false); + } + + #[Test] + public function it_does_not_log_permission_changes_when_activity_logging_is_off(): void + { + $role = Role::query()->create(['name' => 'editor', 'guard_name' => 'web']); + $permission = Permission::query()->create(['name' => 'view_exports', 'guard_name' => 'web']); + + $role->syncPermissionIds([$permission->id]); + + $this->assertDatabaseHas('role_has_permissions', ['role_id' => $role->id, 'permission_id' => $permission->id]); + $this->assertEquals(0, Activity::query()->count()); + } +} diff --git a/tests/Feature/AppPolicyOverrideTest.php b/tests/Feature/AppPolicyOverrideTest.php new file mode 100644 index 0000000..ee85954 --- /dev/null +++ b/tests/Feature/AppPolicyOverrideTest.php @@ -0,0 +1,31 @@ +assertInstanceOf(AppPostPolicy::class, Gate::getPolicyFor(Post::class)); + } +} diff --git a/tests/Feature/FrontendAssetsTest.php b/tests/Feature/FrontendAssetsTest.php new file mode 100644 index 0000000..b02b2f5 --- /dev/null +++ b/tests/Feature/FrontendAssetsTest.php @@ -0,0 +1,29 @@ +assertTrue($manifest->has('permissions::roles')); + + $this->artisan('manifest:list', ['package' => 'permissions'])->assertSuccessful(); + } + + #[Test] + public function every_page_the_controller_renders_exists(): void + { + foreach (['Index', 'Create', 'Edit', 'Show'] as $page) { + $this->assertFileExists(__DIR__ . "/../../resources/js/Pages/Roles/{$page}.tsx"); + $this->assertFileExists(__DIR__ . '/../../resources/views/roles/' . strtolower($page) . '.blade.php'); + } + } +} diff --git a/tests/Feature/HasRolesTest.php b/tests/Feature/HasRolesTest.php index 8e18ac9..065f7f8 100644 --- a/tests/Feature/HasRolesTest.php +++ b/tests/Feature/HasRolesTest.php @@ -4,26 +4,19 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; -use Javaabu\Activitylog\Models\Activity; use Javaabu\Permissions\Events\UserRoleUpdated; -use Javaabu\Permissions\Tests\InteractsWithDatabase; use Javaabu\Permissions\Models\Role; +use Javaabu\Permissions\Tests\InteractsWithDatabase; use Javaabu\Permissions\Tests\Models\User; use Javaabu\Permissions\Tests\Policies\UserPolicy; use Javaabu\Permissions\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; +use Spatie\Activitylog\Models\Activity; class HasRolesTest extends TestCase { use InteractsWithDatabase; - public function setUp(): void - { - parent::setUp(); - - $this->runMigrations(); - } - #[Test] public function it_skips_fake_roles_when_setting_user_roles(): void { @@ -53,7 +46,7 @@ public function it_can_assign_a_single_role(): void $role = new Role([ 'name' => 'Test Role', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role->save(); @@ -80,13 +73,13 @@ public function it_can_assign_multiple_roles(): void $role_1 = new Role([ 'name' => 'Test Role', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role_2 = new Role([ 'name' => 'Test Role 2', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role_1->save(); @@ -113,7 +106,7 @@ public function it_can_assign_multiple_roles(): void } #[Test] - function an_event_is_emitted_when_the_user_role_is_updated() + public function an_event_is_emitted_when_the_user_role_is_updated() { Event::fake(); @@ -125,7 +118,7 @@ function an_event_is_emitted_when_the_user_role_is_updated() $role = new Role([ 'name' => 'Test Role', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role->save(); @@ -146,7 +139,7 @@ function an_event_is_emitted_when_the_user_role_is_updated() } #[Test] - function it_logs_the_new_and_old_roles_when_a_user_role_is_updated() + public function it_logs_the_new_and_old_roles_when_a_user_role_is_updated() { Gate::policy(User::class, UserPolicy::class); @@ -156,7 +149,7 @@ function it_logs_the_new_and_old_roles_when_a_user_role_is_updated() $role = new Role([ 'name' => 'Test Role', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role->save(); @@ -172,8 +165,8 @@ function it_logs_the_new_and_old_roles_when_a_user_role_is_updated() ]); /** @var Activity $log */ - $log = $user->activities()->latest('id')->first(); - $changes = $log->changes(); + $log = Activity::query()->where('subject_type', $user->getMorphClass())->where('subject_id', $user->id)->latest('id')->first(); + $changes = $this->loggedChanges($log); $old = $changes->get('old'); $new = $changes->get('attributes'); diff --git a/tests/Feature/ManifestCheckTest.php b/tests/Feature/ManifestCheckTest.php new file mode 100644 index 0000000..5e52905 --- /dev/null +++ b/tests/Feature/ManifestCheckTest.php @@ -0,0 +1,103 @@ + '0.2.1', + 'flatpickr' => '4.6.13', + 'laravel-vite-plugin' => '2.0.1', + 'tailwindcss' => '4.1.12', + 'tom-select' => '2.4.3', + 'vite' => '7.1.3', + ]; + + protected function tearDown(): void + { + File::delete(base_path('package.json')); + File::deleteDirectory(base_path('node_modules')); + + parent::tearDown(); + } + + /** + * @param array $installed + */ + protected function app(array $installed): void + { + File::put(base_path('package.json'), json_encode([ + 'devDependencies' => (object) array_map(fn ($version) => str_starts_with($version, 'github:') ? $version : "^{$version}", $installed), + ])); + + foreach ($installed as $name => $version) { + File::ensureDirectoryExists(base_path("node_modules/{$name}")); + File::put(base_path("node_modules/{$name}/package.json"), json_encode(['name' => $name, 'version' => str_starts_with($version, 'github:') ? '0.1.0' : $version])); + } + } + + /** + * @return array> + */ + protected function check(array $options = []): array + { + Artisan::call('manifest:check', ['--json' => true, ...$options]); + + return collect(json_decode(Artisan::output(), true))->keyBy('name')->all(); + } + + #[Test] + public function a_blade_app_passes_without_installing_react(): void + { + $this->app($this->blade_app); + + $this->artisan('manifest:check') + ->expectsOutputToContain('Everything the packages need is installed.') + ->assertSuccessful(); + + $rows = $this->check(); + + foreach (['react', 'react-dom', '@inertiajs/react', '@javaabu/forms'] as $name) { + $this->assertArrayNotHasKey($name, $rows); + } + } + + #[Test] + public function an_inertia_app_is_asked_for_what_the_react_pages_need(): void + { + $this->app([...$this->blade_app, '@inertiajs/react' => '2.1.2']); + + $rows = $this->check(); + + foreach (['@javaabu/forms', 'react', 'react-dom'] as $name) { + $this->assertEquals('missing', $rows[$name]['status'], $name); + $this->assertEquals(['pages'], $rows[$name]['scopes'], $name); + $this->assertContains('permissions', $rows[$name]['needed_by'], $name); + } + + $this->assertEquals(['^2.0 || ^3.0'], $rows['@inertiajs/react']['constraints']); + $this->assertContains('pages', $rows['@javaabu/onu']['scopes']); + } + + #[Test] + public function an_inertia_app_with_the_react_packages_passes(): void + { + $this->app([ + ...$this->blade_app, + '@inertiajs/react' => '2.1.2', + '@javaabu/forms' => 'github:Javaabu/react-forms#next-gen', + 'react' => '19.1.1', + 'react-dom' => '19.1.1', + ]); + + $this->artisan('manifest:check')->assertSuccessful(); + } +} diff --git a/tests/Feature/ModelsTest.php b/tests/Feature/ModelsTest.php new file mode 100644 index 0000000..16b5a11 --- /dev/null +++ b/tests/Feature/ModelsTest.php @@ -0,0 +1,112 @@ +app['translator']->addLines(['*.View exports' => 'އެކްސްޕޯޓްތައް ބެލުން', '*.Exports' => 'އެކްސްޕޯޓްތައް'], 'dv'); + $this->app->setLocale('dv'); + + Permissions::register('exports', ['view_exports' => 'View exports'], label: 'Exports'); + Permissions::sync(); + + $permission = Permission::findByName('view_exports'); + $manual = Permission::query()->create(['name' => 'manual_permission', 'guard_name' => 'web']); + + $this->assertEquals('އެކްސްޕޯޓްތައް ބެލުން', $permission->translated_description); + $this->assertEquals('އެކްސްޕޯޓްތައް', $permission->group_label); + $this->assertEquals('manual_permission', $manual->translated_description); + $this->assertEquals('އެހެނިހެން', $manual->group_label); + } + + #[Test] + public function roles_can_be_searched_and_translate_their_description(): void + { + $this->app->setLocale('dv'); + + Permissions::sync(); + + Role::factory()->create(['name' => 'editor', 'description' => 'Content editor', 'guard_name' => 'web']); + + $this->assertEquals(['editor'], Role::query()->search('content')->pluck('name')->all()); + $this->assertEquals(['super_admin'], Role::query()->search('super')->pluck('name')->all()); + $this->assertEquals(2, Role::query()->search(null)->count()); + $this->assertEquals('ސުޕަރ އެޑްމިން', Role::findByName('super_admin')->translated_description); + } + + #[Test] + public function factories_make_permissions_and_roles(): void + { + $permission = Permission::factory()->create(['guard_name' => 'web']); + + $this->assertNotEmpty($permission->model); + $this->assertDatabaseHas('permissions', ['id' => $permission->id]); + } + + #[Test] + public function a_new_user_gets_the_default_role_when_the_causer_cannot_choose_one(): void + { + Gate::before(fn () => false); + config()->set('permissions.default_role', 'member'); + + Role::query()->create(['name' => 'member', 'guard_name' => 'web']); + Role::query()->create(['name' => 'admin', 'guard_name' => 'web']); + + $causer = User::query()->create(['name' => 'އަޙްމަދު', 'email' => 'ahmed@example.com']); + $user = User::query()->create(['name' => 'މަރިޔަމް', 'email' => 'mariyam@example.com']); + $existing = User::query()->create(['name' => 'ޢަލީ', 'email' => 'ali@example.com']); + + $user->updateRole('admin', new: true, causer: $causer); + $existing->updateRole('admin', causer: $causer); + + $this->assertEquals(['member'], $user->roles()->pluck('name')->all()); + $this->assertEmpty($existing->roles()->pluck('name')->all()); + } + + #[Test] + public function a_causer_that_may_update_roles_can_remove_them_all(): void + { + Gate::before(fn () => true); + + Role::query()->create(['name' => 'admin', 'guard_name' => 'web']); + + $user = User::query()->create(['name' => 'މަރިޔަމް', 'email' => 'mariyam@example.com']); + $user->assignRole('admin'); + + $user->updateRole([], causer: $user); + + $this->assertEmpty($user->roles()->pluck('name')->all()); + } + + #[Test] + public function it_finds_models_whose_roles_have_all_the_permissions(): void + { + Permissions::resource('exports'); + Permissions::role('viewer', permissions: ['view_exports']); + Permissions::sync(); + + $admin = User::query()->create(['name' => 'އަޙްމަދު', 'email' => 'ahmed@example.com']); + $viewer = User::query()->create(['name' => 'މަރިޔަމް', 'email' => 'mariyam@example.com']); + + $admin->assignRole('super_admin'); + $viewer->assignRole('viewer'); + + $this->assertEqualsCanonicalizing([$admin->id, $viewer->id], User::query()->whereRoleHasPermissions('view_exports')->pluck('id')->all()); + $this->assertEquals([$admin->id], User::query()->whereRoleHasPermissions(['view_exports', 'edit_exports'])->pluck('id')->all()); + } +} diff --git a/tests/Feature/MultiAuthTest.php b/tests/Feature/MultiAuthTest.php new file mode 100644 index 0000000..112fbfb --- /dev/null +++ b/tests/Feature/MultiAuthTest.php @@ -0,0 +1,132 @@ +set('permissions.guards', ['web', 'web_admin']); + + Permissions::resource('exports'); + Permissions::register('reports', ['view_reports' => 'View reports'])->guards('web_admin'); + Permissions::role('auditor', 'Auditor', ['view_*']); + + Permissions::sync(); + } + + #[Test] + public function roles_and_permissions_are_kept_per_guard(): void + { + $this->assertEquals(2, Role::query()->where('name', 'auditor')->count()); + $this->assertEquals(['view_exports'], Role::findByName('auditor', 'web')->permissions->pluck('name')->all()); + $this->assertEqualsCanonicalizing( + ['view_exports', 'view_reports'], + Role::findByName('auditor', 'web_admin')->permissions->pluck('name')->all(), + ); + + $this->assertEquals(['web_admin'], Role::query()->forGuard('web_admin')->where('name', 'auditor')->pluck('guard_name')->all()); + $this->assertEquals(5, Permission::query()->forGuard('web_admin')->count()); + $this->assertEquals(1, Permission::query()->forGuard('web_admin')->inGroup('reports')->count()); + } + + #[Test] + public function each_user_model_gets_roles_of_its_own_guard(): void + { + $user = $this->createUser(); + $admin = $this->createAdmin(); + + $user->assignRole('auditor'); + $admin->assignRole('auditor'); + + $this->assertEquals('web', Permissions::guardFor($user)); + $this->assertEquals('web_admin', Permissions::guardFor($admin)); + + $this->assertTrue($admin->can('view_reports')); + $this->assertFalse($user->can('view_reports')); + $this->assertEquals('web_admin', $admin->role->guard_name); + } + + #[Test] + public function it_checks_permissions_against_a_specific_guard(): void + { + $admin = $this->createAdmin(); + $admin->assignRole('auditor'); + + $this->assertTrue($admin->anyPermission(['edit_exports', 'view_reports'], 'web_admin')); + $this->assertFalse($admin->anyPermission('edit_exports', 'web_admin')); + $this->assertFalse($admin->anyPermission('view_reports', 'web')); + $this->assertFalse($admin->anyPermission('does_not_exist', 'web_admin')); + } + + #[Test] + public function it_checks_the_user_signed_in_on_a_guard(): void + { + $admin = $this->createAdmin(); + $admin->assignRole('auditor'); + + $this->assertFalse(Permissions::check('view_reports', 'web_admin')); + + $this->actingAs($admin, 'web_admin'); + + $this->assertTrue(Permissions::check('view_reports', 'web_admin')); + $this->assertTrue(Permissions::check(['edit_exports', 'view_exports'], 'web_admin')); + $this->assertFalse(Permissions::check('edit_exports', 'web_admin')); + $this->assertFalse(Permissions::check('view_reports', 'web')); + } + + #[Test] + public function the_anypermission_blade_directive_takes_a_guard(): void + { + $admin = $this->createAdmin(); + $admin->assignRole('auditor'); + + $this->actingAs($admin, 'web_admin'); + + $view = "@anypermission(['view_reports'], 'web_admin')ތަފްސީލު@endanypermission|@anypermission('view_reports', 'web')ނެތް@endanypermission"; + + $this->assertEquals('ތަފްސީލު|', Blade::render($view)); + } + + #[Test] + public function update_role_only_uses_roles_of_the_models_guard(): void + { + $admin = $this->createAdmin(); + Role::query()->create(['name' => 'web_only', 'guard_name' => 'web']); + + $causer = $this->createUser(); + Gate::define('updateRole', fn () => true); + + $admin->updateRole(['web_only'], causer: $causer); + $this->assertEmpty($admin->roles()->pluck('name')->all()); + + $admin->updateRole(['web_only', 'auditor'], causer: $causer); + $this->assertEquals(['auditor'], $admin->roles()->pluck('name')->all()); + $this->assertEquals('web_admin', $admin->roles()->first()->guard_name); + } + + protected function createUser(): User + { + return User::query()->create(['name' => 'އަޙްމަދު', 'email' => 'ahmed@example.com']); + } + + protected function createAdmin(): Admin + { + return Admin::query()->create(['name' => 'ޢާއިޝަތު', 'email' => 'aishath@example.com']); + } +} diff --git a/tests/Feature/PackageRegistrationTest.php b/tests/Feature/PackageRegistrationTest.php new file mode 100644 index 0000000..6f30517 --- /dev/null +++ b/tests/Feature/PackageRegistrationTest.php @@ -0,0 +1,56 @@ +assertTrue(Permissions::has('edit_posts')); + $this->assertEquals('Posts', Permissions::label('posts')); + $this->assertInstanceOf(PostPolicy::class, Gate::getPolicyFor(Post::class)); + } + + #[Test] + public function the_package_permissions_sync_and_authorize_through_the_policy(): void + { + config()->set('permissions.roles', ['editor' => ['description' => 'Editor']]); + + $this->artisan('permissions:sync')->assertSuccessful(); + + $user = User::query()->create([ + 'name' => 'މަރިޔަމް', + 'email' => 'mariyam@example.com', + ]); + + $this->assertFalse($user->can('create', Post::class)); + + $user->assignRole('editor'); + + $this->assertTrue($user->can('create', Post::class)); + $this->assertFalse($user->can('publish', Post::class)); + } +} diff --git a/tests/Feature/PermissionRegistryTest.php b/tests/Feature/PermissionRegistryTest.php new file mode 100644 index 0000000..446cc0d --- /dev/null +++ b/tests/Feature/PermissionRegistryTest.php @@ -0,0 +1,129 @@ + 'View exports', + 'download_exports' => 'Download exports', + ], label: 'Exports'); + + $this->assertTrue(Permissions::has('view_exports')); + $this->assertEquals('Download exports', Permissions::description('download_exports')); + $this->assertEquals('exports', Permissions::groupOf('view_exports')->name); + $this->assertEquals('Exports', Permissions::label('exports')); + $this->assertEquals(['view_exports', 'download_exports'], array_keys(Permissions::permissions())); + } + + #[Test] + public function it_adds_to_a_group_registered_twice(): void + { + Permissions::register('exports', ['view_exports' => 'View exports']); + Permissions::register('exports', ['delete_exports' => 'Delete exports']); + + $this->assertCount(1, Permissions::groups()); + $this->assertEquals(['view_exports', 'delete_exports'], array_keys(Permissions::group('exports')->getPermissions())); + } + + #[Test] + public function it_makes_descriptions_from_names_given_as_a_list(): void + { + Permissions::register('reports', ['view_reports', 'download_reports']); + + $this->assertEquals('View reports', Permissions::description('view_reports')); + $this->assertEquals('Reports', Permissions::label('reports')); + } + + #[Test] + public function it_registers_the_standard_permissions_of_a_resource(): void + { + Permissions::resource('oauth_clients', 'OAuth2 clients', ['view', 'edit', 'edit_others', 'force_delete', 'archive']); + + $this->assertEquals([ + 'view_oauth_clients' => 'View OAuth2 clients', + 'edit_oauth_clients' => 'Edit OAuth2 clients', + 'edit_others_oauth_clients' => 'Edit all OAuth2 clients', + 'force_delete_oauth_clients' => 'Permanently delete OAuth2 clients', + 'archive_oauth_clients' => 'Archive OAuth2 clients', + ], Permissions::permissions()); + + $this->assertEquals('Oauth Clients', Permissions::label('oauth_clients')); + } + + #[Test] + public function it_uses_the_default_actions_and_group_name_for_a_resource(): void + { + Permissions::resource('blog_posts'); + + $this->assertEquals([ + 'view_blog_posts' => 'View blog posts', + 'edit_blog_posts' => 'Edit blog posts', + 'delete_blog_posts' => 'Delete blog posts', + 'force_delete_blog_posts' => 'Permanently delete blog posts', + ], Permissions::permissions()); + } + + #[Test] + public function it_uses_the_default_auth_guard_when_no_guards_are_configured(): void + { + $this->assertEquals(['web'], Permissions::defaultGuards()); + } + + #[Test] + public function it_uses_the_configured_guards(): void + { + config()->set('permissions.guards', ['web_admin', 'api']); + + Permissions::register('exports', ['view_exports']); + Permissions::register('reports', ['view_reports'])->guards('web'); + + $this->assertEquals(['web_admin', 'api'], Permissions::guardsFor(Permissions::group('exports'))); + $this->assertEquals(['web'], Permissions::guardsFor(Permissions::group('reports'))); + $this->assertEquals(['view_reports'], array_keys(Permissions::permissions('web'))); + $this->assertEquals(['view_exports'], array_keys(Permissions::permissions('api'))); + $this->assertEquals(['web_admin', 'api', 'web'], Permissions::allGuards()); + } + + #[Test] + public function it_merges_configured_roles_over_roles_defined_in_code(): void + { + config()->set('permissions.roles', [ + 'editor' => ['description' => 'Editor', 'permissions' => ['edit_*']], + ]); + + Permissions::role('editor', 'Content editor', ['view_*']); + Permissions::role('viewer', permissions: ['view_*'], guards: 'web_admin'); + + $roles = Permissions::roles(); + + $this->assertEquals(['editor', 'viewer'], array_keys($roles)); + $this->assertEquals('Editor', $roles['editor']->getDescription()); + $this->assertEquals(['edit_*'], $roles['editor']->permissions); + $this->assertEquals('Viewer', $roles['viewer']->getDescription()); + $this->assertEquals(['web_admin'], $roles['viewer']->guards); + } + + #[Test] + public function it_builds_conventional_permission_names(): void + { + $this->assertEquals('force_delete_exports', Permissions::permissionName('force_delete', 'exports')); + } + + #[Test] + public function it_returns_unknown_lookups_as_null(): void + { + $this->assertFalse(Permissions::has('nothing')); + $this->assertNull(Permissions::description('nothing')); + $this->assertNull(Permissions::groupOf('nothing')); + $this->assertNull(Permissions::group('nothing')); + $this->assertEquals('Blog Posts', Permissions::label('blog_posts')); + } +} diff --git a/tests/Feature/PermissionsTest.php b/tests/Feature/PermissionsTest.php index c789b68..749520b 100644 --- a/tests/Feature/PermissionsTest.php +++ b/tests/Feature/PermissionsTest.php @@ -2,9 +2,9 @@ namespace Javaabu\Permissions\Tests\Feature; -use Javaabu\Permissions\Tests\InteractsWithDatabase; use Javaabu\Permissions\Models\Permission; use Javaabu\Permissions\Models\Role; +use Javaabu\Permissions\Tests\InteractsWithDatabase; use Javaabu\Permissions\Tests\Models\User; use Javaabu\Permissions\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; @@ -13,13 +13,6 @@ class PermissionsTest extends TestCase { use InteractsWithDatabase; - public function setUp(): void - { - parent::setUp(); - - $this->runMigrations(); - } - #[Test] public function it_can_check_if_a_user_has_a_permission(): void { @@ -29,7 +22,7 @@ public function it_can_check_if_a_user_has_a_permission(): void $role = new Role([ 'name' => 'Test Role', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role->save(); @@ -38,7 +31,7 @@ public function it_can_check_if_a_user_has_a_permission(): void 'name' => 'test', 'description' => 'Test description', 'guard_name' => 'web', - 'model' => 'test' + 'model' => 'test', ]); $permission->save(); @@ -49,6 +42,4 @@ public function it_can_check_if_a_user_has_a_permission(): void $this->assertTrue($user->can('test')); } - - } diff --git a/tests/Feature/PolicyRegistrationTest.php b/tests/Feature/PolicyRegistrationTest.php new file mode 100644 index 0000000..cc9fe6b --- /dev/null +++ b/tests/Feature/PolicyRegistrationTest.php @@ -0,0 +1,72 @@ +assertInstanceOf(PostPolicy::class, Gate::getPolicyFor(Post::class)); + $this->assertEquals([Post::class => PostPolicy::class], Permissions::policies()); + } + + #[Test] + public function the_config_overrides_a_package_policy(): void + { + config()->set('permissions.policies', [Post::class => AppPostPolicy::class]); + + Permissions::policy(Post::class, PostPolicy::class); + + $this->assertInstanceOf(AppPostPolicy::class, Gate::getPolicyFor(Post::class)); + $this->assertEquals([Post::class => AppPostPolicy::class], Permissions::policies()); + } + + #[Test] + public function the_config_overrides_a_policy_already_on_the_gate(): void + { + Gate::policy(Post::class, PostPolicy::class); + config()->set('permissions.policies', [Post::class => AppPostPolicy::class]); + + Permissions::applyPolicies(); + + $this->assertInstanceOf(AppPostPolicy::class, Gate::getPolicyFor(Post::class)); + } + + #[Test] + public function a_policy_the_app_defined_on_the_gate_wins_over_a_package_policy(): void + { + Gate::policy(Post::class, AppPostPolicy::class); + + Permissions::policy(Post::class, PostPolicy::class); + + $this->assertInstanceOf(AppPostPolicy::class, Gate::getPolicyFor(Post::class)); + } + + #[Test] + public function package_policies_wait_for_the_app_to_boot_so_the_app_can_override_them(): void + { + $registry = new PermissionRegistry($this->app); + + $registry->policy(Post::class, PostPolicy::class); + + $this->assertArrayNotHasKey(Post::class, Gate::policies()); + + Gate::policy(Post::class, AppPostPolicy::class); + + $registry->applyPolicies(); + + $this->assertInstanceOf(AppPostPolicy::class, Gate::getPolicyFor(Post::class)); + } +} diff --git a/tests/Feature/ResourcePolicyTest.php b/tests/Feature/ResourcePolicyTest.php new file mode 100644 index 0000000..7dbd8e4 --- /dev/null +++ b/tests/Feature/ResourcePolicyTest.php @@ -0,0 +1,161 @@ +create(['name' => "{$action}_posts", 'guard_name' => 'web']); + Permission::query()->create(['name' => "{$action}_posts", 'guard_name' => 'web_admin']); + } + } + + #[Test] + public function it_maps_the_standard_abilities_to_permissions(): void + { + Gate::policy(Post::class, PostPolicy::class); + + $post = $this->createPost(); + + $this->assertAbilities($this->createUser(), $post, []); + $this->assertAbilities($this->createUser(['view_posts']), $post, ['viewAny', 'view']); + $this->assertAbilities($this->createUser(['edit_posts']), $post, ['create', 'update']); + $this->assertAbilities($this->createUser(['delete_posts']), $post, ['delete', 'restore', 'viewTrash']); + $this->assertAbilities($this->createUser(['force_delete_posts']), $post, ['forceDelete', 'restore', 'viewTrash']); + } + + #[Test] + public function it_maps_custom_abilities_and_denies_unknown_ones(): void + { + Gate::policy(Post::class, PostPolicy::class); + + $publisher = $this->createUser(['publish_posts']); + $approver = $this->createUser(['approve_posts']); + + $this->assertTrue($publisher->can('publish', Post::class)); + $this->assertTrue($publisher->can('review', Post::class)); + $this->assertFalse($approver->can('publish', Post::class)); + $this->assertTrue($approver->can('review', Post::class)); + $this->assertFalse($publisher->can('somethingElse', Post::class)); + $this->assertFalse((new PostPolicy)->publish()); + } + + #[Test] + public function guests_are_denied(): void + { + Gate::policy(Post::class, PostPolicy::class); + + $this->assertFalse(Gate::allows('viewAny', Post::class)); + $this->assertFalse(Gate::allows('publish', Post::class)); + } + + #[Test] + public function owned_resources_need_the_others_permission_for_models_the_user_does_not_own(): void + { + Gate::policy(Post::class, OwnedPostPolicy::class); + + $author = $this->createUser(['edit_posts', 'delete_posts']); + $other = $this->createUser(['edit_posts', 'delete_posts']); + $editor = $this->createUser(['edit_others_posts', 'delete_others_posts']); + + $post = $this->createPost($author); + + $this->assertAbilities($author, $post, ['create', 'view', 'update', 'delete', 'restore', 'viewTrash']); + $this->assertAbilities($other, $post, ['create', 'viewTrash']); + $this->assertAbilities($editor, $post, ['view', 'update', 'delete', 'restore']); + } + + #[Test] + public function models_without_an_owner_check_are_not_owned(): void + { + $policy = new OwnedPostPolicy; + $user = $this->createUser(['edit_posts']); + + $this->assertFalse($policy->update($user, $this->createUser())); + } + + #[Test] + public function a_policy_with_a_guard_checks_that_guards_permissions(): void + { + Gate::policy(Post::class, AdminPostPolicy::class); + + $admin = Admin::query()->create(['name' => 'ފާތިމަތު', 'email' => 'fathimath@example.com']); + $admin->givePermissionTo('view_posts'); + + $this->assertTrue($admin->can('viewAny', Post::class)); + $this->assertFalse($admin->can('create', Post::class)); + } + + #[Test] + public function it_exposes_the_permission_name_for_an_action(): void + { + $this->assertEquals('edit_others_posts', (new PostPolicy)->permission('edit_others')); + } + + /** + * @param array $permissions + */ + protected function createUser(array $permissions = []): User + { + static $count = 0; + + $count++; + + $user = User::query()->create(['name' => "ޔޫސަރ {$count}", 'email' => "user{$count}@example.com"]); + $user->givePermissionTo($permissions); + + return $user; + } + + protected function createPost(?User $author = null): Post + { + $post = new Post(['title' => 'ޓެސްޓް ޕޯސްޓް']); + $post->author()->associate($author); + $post->save(); + + return $post; + } + + /** + * @param array $allowed + */ + protected function assertAbilities(User $user, Post $post, array $allowed): void + { + $abilities = [ + 'viewAny' => Post::class, + 'viewTrash' => Post::class, + 'create' => Post::class, + 'view' => $post, + 'update' => $post, + 'delete' => $post, + 'restore' => $post, + 'forceDelete' => $post, + ]; + + foreach ($abilities as $ability => $argument) { + $this->assertEquals( + in_array($ability, $allowed, true), + $user->can($ability, $argument), + "Unexpected result for {$ability}", + ); + } + } +} diff --git a/tests/Feature/RolesTest.php b/tests/Feature/RolesTest.php index 6badcb9..5ba877f 100644 --- a/tests/Feature/RolesTest.php +++ b/tests/Feature/RolesTest.php @@ -3,26 +3,19 @@ namespace Javaabu\Permissions\Tests\Feature; use Illuminate\Support\Facades\Event; -use Javaabu\Activitylog\Models\Activity; use Javaabu\Permissions\Events\RolePermissionsUpdated; use Javaabu\Permissions\Models\Permission; -use Javaabu\Permissions\Tests\InteractsWithDatabase; use Javaabu\Permissions\Models\Role; +use Javaabu\Permissions\Tests\InteractsWithDatabase; use Javaabu\Permissions\Tests\Models\User; use Javaabu\Permissions\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; +use Spatie\Activitylog\Models\Activity; class RolesTest extends TestCase { use InteractsWithDatabase; - public function setUp(): void - { - parent::setUp(); - - $this->runMigrations(); - } - #[Test] public function it_can_sync_the_role_permissions(): void { @@ -32,7 +25,7 @@ public function it_can_sync_the_role_permissions(): void $role = new Role([ 'name' => 'Test Role', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role->save(); @@ -41,7 +34,7 @@ public function it_can_sync_the_role_permissions(): void 'name' => 'test', 'description' => 'Test description', 'guard_name' => 'web', - 'model' => 'test' + 'model' => 'test', ]); $permission->save(); @@ -55,7 +48,7 @@ public function it_can_sync_the_role_permissions(): void } #[Test] - function an_event_is_emitted_when_a_roles_permissions_is_updated() + public function an_event_is_emitted_when_a_roles_permissions_is_updated() { Event::fake(); @@ -65,7 +58,7 @@ function an_event_is_emitted_when_a_roles_permissions_is_updated() $role = new Role([ 'name' => 'Test Role', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role->save(); @@ -74,7 +67,7 @@ function an_event_is_emitted_when_a_roles_permissions_is_updated() 'name' => 'test', 'description' => 'Test description', 'guard_name' => 'web', - 'model' => 'test' + 'model' => 'test', ]); $permission->save(); @@ -94,7 +87,7 @@ function an_event_is_emitted_when_a_roles_permissions_is_updated() } #[Test] - function it_logs_the_new_and_old_permissions_when_a_role_is_updated() + public function it_logs_the_new_and_old_permissions_when_a_role_is_updated() { $user = new User(['name' => 'John', 'email' => 'test@example.com']); $user->save(); @@ -102,7 +95,7 @@ function it_logs_the_new_and_old_permissions_when_a_role_is_updated() $role = new Role([ 'name' => 'Test Role', 'description' => 'Test description', - 'guard_name' => 'web' + 'guard_name' => 'web', ]); $role->save(); @@ -111,7 +104,7 @@ function it_logs_the_new_and_old_permissions_when_a_role_is_updated() 'name' => 'test', 'description' => 'Test description', 'guard_name' => 'web', - 'model' => 'test' + 'model' => 'test', ]); $permission->save(); @@ -126,8 +119,8 @@ function it_logs_the_new_and_old_permissions_when_a_role_is_updated() ]); /** @var Activity $log */ - $log = $role->activities()->latest('id')->first(); - $changes = $log->changes(); + $log = Activity::query()->where('subject_type', $role->getMorphClass())->where('subject_id', $role->id)->latest('id')->first(); + $changes = $this->loggedChanges($log); $old = $changes->get('old'); $new = $changes->get('attributes'); diff --git a/tests/Feature/SyncPermissionsTest.php b/tests/Feature/SyncPermissionsTest.php new file mode 100644 index 0000000..815af12 --- /dev/null +++ b/tests/Feature/SyncPermissionsTest.php @@ -0,0 +1,234 @@ + 'View exports']); + + $this->artisan('permissions:sync') + ->expectsOutputToContain('Created permission web:view_exports') + ->assertSuccessful(); + + $this->assertDatabaseHas('permissions', [ + 'name' => 'view_exports', + 'guard_name' => 'web', + 'model' => 'exports', + 'description' => 'View exports', + ]); + } + + #[Test] + public function it_creates_permissions_for_every_configured_guard(): void + { + config()->set('permissions.guards', ['web', 'web_admin']); + + Permissions::register('exports', ['view_exports' => 'View exports']); + + Permissions::sync(); + + $this->assertDatabaseHas('permissions', ['name' => 'view_exports', 'guard_name' => 'web']); + $this->assertDatabaseHas('permissions', ['name' => 'view_exports', 'guard_name' => 'web_admin']); + } + + #[Test] + public function it_only_syncs_the_given_guards(): void + { + config()->set('permissions.guards', ['web', 'web_admin']); + + Permissions::register('exports', ['view_exports' => 'View exports']); + + $this->artisan('permissions:sync', ['--guard' => ['web_admin']])->assertSuccessful(); + + $this->assertDatabaseMissing('permissions', ['name' => 'view_exports', 'guard_name' => 'web']); + $this->assertDatabaseHas('permissions', ['name' => 'view_exports', 'guard_name' => 'web_admin']); + } + + #[Test] + public function it_updates_changed_descriptions_and_is_idempotent(): void + { + Permissions::register('exports', ['view_exports' => 'View exports']); + Permissions::sync(); + + Permissions::register('exports', ['view_exports' => 'View all exports']); + $result = Permissions::sync(); + + $this->assertEquals(['web:view_exports'], $result->updated_permissions); + $this->assertDatabaseHas('permissions', ['name' => 'view_exports', 'description' => 'View all exports']); + + $result = Permissions::sync(); + + $this->assertEmpty($result->created_permissions); + $this->assertEmpty($result->updated_permissions); + $this->assertEquals(1, Permission::query()->where('name', 'view_exports')->count()); + } + + #[Test] + public function it_prunes_permissions_removed_from_a_registered_group_only_when_asked(): void + { + Permission::query()->create(['name' => 'old_exports', 'guard_name' => 'web', 'model' => 'exports']); + Permission::query()->create(['name' => 'manual_permission', 'guard_name' => 'web', 'model' => 'manual']); + + Permissions::register('exports', ['view_exports' => 'View exports']); + + $this->artisan('permissions:sync')->assertSuccessful(); + + $this->assertDatabaseHas('permissions', ['name' => 'old_exports']); + + $this->artisan('permissions:sync', ['--prune' => true]) + ->expectsOutputToContain('Deleted permission web:old_exports') + ->assertSuccessful(); + + $this->assertDatabaseMissing('permissions', ['name' => 'old_exports']); + $this->assertDatabaseHas('permissions', ['name' => 'manual_permission']); + } + + #[Test] + public function it_creates_the_default_super_admin_role_with_every_permission(): void + { + Permission::query()->create(['name' => 'manual_permission', 'guard_name' => 'web']); + Permissions::resource('exports'); + + Permissions::sync(); + + $role = Role::findByName('super_admin', 'web'); + + $this->assertEquals('Super Admin', $role->description); + $this->assertEqualsCanonicalizing( + ['view_exports', 'edit_exports', 'delete_exports', 'force_delete_exports', 'manual_permission'], + $role->permissions->pluck('name')->all(), + ); + } + + #[Test] + public function it_keeps_every_permission_roles_in_sync(): void + { + Permissions::register('exports', ['view_exports' => 'View exports']); + Permissions::sync(); + + Permissions::register('reports', ['view_reports' => 'View reports']); + Permissions::sync(prune: true); + + $role = Role::findByName('super_admin', 'web'); + + $this->assertEqualsCanonicalizing(['view_exports', 'view_reports'], $role->permissions->pluck('name')->all()); + } + + #[Test] + public function it_gives_roles_the_permissions_matching_their_patterns_without_removing_others(): void + { + config()->set('permissions.roles', [ + 'viewer' => ['description' => 'Viewer', 'permissions' => ['view_*']], + ]); + + Permissions::resource('exports'); + Permissions::sync(); + + $role = Role::findByName('viewer', 'web'); + $role->givePermissionTo('delete_exports'); + + Permissions::sync(); + + $this->assertEqualsCanonicalizing(['view_exports', 'delete_exports'], $role->fresh()->permissions->pluck('name')->all()); + } + + #[Test] + public function it_creates_roles_defined_in_code_for_their_guards(): void + { + config()->set('permissions.roles', []); + + Permissions::register('exports', ['view_exports' => 'View exports'])->guards(['web', 'web_admin']); + Permissions::role('auditor', 'Auditor', ['view_exports'], guards: 'web_admin'); + + $this->artisan('permissions:sync') + ->expectsOutputToContain('Created role web_admin:auditor') + ->assertSuccessful(); + + $this->assertDatabaseMissing('roles', ['name' => 'auditor', 'guard_name' => 'web']); + $this->assertTrue(Role::findByName('auditor', 'web_admin')->hasPermissionTo('view_exports')); + } + + #[Test] + public function it_grants_group_permissions_to_existing_roles_and_skips_missing_ones(): void + { + config()->set('permissions.roles', [ + 'editor' => ['description' => 'Editor', 'permissions' => []], + ]); + + Permissions::resource('exports') + ->grantTo('editor', only: ['view_exports', 'edit_exports', 'not_in_group']) + ->grantTo('missing_role'); + + $this->artisan('permissions:sync') + ->expectsOutputToContain('Skipped grants to web:missing_role') + ->assertSuccessful(); + + $this->assertEqualsCanonicalizing( + ['view_exports', 'edit_exports'], + Role::findByName('editor', 'web')->permissions->pluck('name')->all(), + ); + + $this->assertDatabaseMissing('roles', ['name' => 'missing_role']); + } + + #[Test] + public function it_grants_every_permission_in_the_group_by_default(): void + { + config()->set('permissions.roles', ['editor' => []]); + + Permissions::resource('exports', actions: ['view', 'edit'])->grantTo(['editor']); + + Permissions::sync(); + + $role = Role::findByName('editor', 'web'); + + $this->assertEquals('Editor', $role->description); + $this->assertEqualsCanonicalizing(['view_exports', 'edit_exports'], $role->permissions->pluck('name')->all()); + } + + #[Test] + public function it_can_sync_from_a_seeder(): void + { + Permissions::register('exports', ['view_exports' => 'View exports']); + + $this->seed(PermissionsSeeder::class); + + $this->assertDatabaseHas('permissions', ['name' => 'view_exports']); + $this->assertDatabaseHas('roles', ['name' => 'super_admin']); + } + + #[Test] + public function synced_permissions_are_usable_straight_away(): void + { + $user = $this->createUser(); + + Permissions::register('exports', ['view_exports' => 'View exports']); + Permissions::sync(); + + $user->assignRole('super_admin'); + + $this->assertTrue($user->can('view_exports')); + } + + protected function createUser(): User + { + return User::query()->create([ + 'name' => 'އަޙްމަދު', + 'email' => 'ahmed@example.com', + ]); + } +} diff --git a/tests/Feature/TranslationsTest.php b/tests/Feature/TranslationsTest.php new file mode 100644 index 0000000..992eee3 --- /dev/null +++ b/tests/Feature/TranslationsTest.php @@ -0,0 +1,63 @@ +sourceKeys(); + + $this->assertNotEmpty($keys); + + foreach ($keys as $key) { + $this->assertArrayHasKey($key, $en, "lang/en.json is missing [{$key}]"); + $this->assertArrayHasKey($key, $dv, "lang/dv.json is missing [{$key}]"); + $this->assertMatchesRegularExpression('/[\x{0780}-\x{07BF}]/u', $dv[$key], "[{$key}] isn't in Thaana"); + } + + $this->assertEqualsCanonicalizing(array_keys($en), array_keys($dv)); + } + + #[Test] + public function the_package_strings_are_shown_in_dhivehi(): void + { + $this->app->setLocale('dv'); + + $this->assertEquals('ރޯލް އުފެއްދިއްޖެ', __('Role created')); + $this->assertEquals("'އެޑިޓަރ' އާ ގުޅޭ ރޯލްތައް", __('Roles matching \':search\'', ['search' => 'އެޑިޓަރ'])); + } + + /** @return array */ + protected function sourceKeys(): array + { + $keys = []; + + foreach (['src', 'resources/views'] as $directory) { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../../' . $directory)); + + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + preg_match_all("/__\\('((?:[^'\\\\]|\\\\.)+)'/", file_get_contents($file->getPathname()), $matches); + + foreach ($matches[1] as $key) { + $keys[] = stripslashes($key); + } + } + } + + return array_values(array_unique($keys)); + } +} diff --git a/tests/Fixtures/AppServiceProvider.php b/tests/Fixtures/AppServiceProvider.php new file mode 100644 index 0000000..3b30dcd --- /dev/null +++ b/tests/Fixtures/AppServiceProvider.php @@ -0,0 +1,19 @@ +json(['custom' => true]); + } +} diff --git a/tests/Fixtures/DenyAllPolicy.php b/tests/Fixtures/DenyAllPolicy.php new file mode 100644 index 0000000..82647c4 --- /dev/null +++ b/tests/Fixtures/DenyAllPolicy.php @@ -0,0 +1,11 @@ +grantTo('editor'); + + Permissions::policy(Post::class, PostPolicy::class); + } +} diff --git a/tests/Http/BladePagesTest.php b/tests/Http/BladePagesTest.php new file mode 100644 index 0000000..568e3ed --- /dev/null +++ b/tests/Http/BladePagesTest.php @@ -0,0 +1,209 @@ +set('permissions.guards', ['web', 'web_admin']); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpRoleRoutes(); + } + + #[Test] + public function the_index_lists_the_roles_with_their_permission_counts(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'ލިޔުންތައް ބަދަލުކުރާ މީހުން', 'guard_name' => 'web_admin']); + $role->givePermissionTo(['view_posts', 'edit_posts']); + + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles') + ->assertOk() + ->assertSee('

Roles

', false) + ->assertSee('New role') + ->assertSee(url('/admin/roles/create'), false) + ->assertSee('ލިޔުންތައް ބަދަލުކުރާ މީހުން') + ->assertSee('2 permissions') + ->assertSee(url("/admin/roles/{$role->id}"), false) + ->assertSee(url("/admin/roles/{$role->id}/edit"), false) + ->assertSee('data-confirm="Delete this role? Users with it lose its permissions."', false) + ->assertSee('Search roles'); + + Manifest::assertAssetRequired('permissions::roles'); + } + + #[Test] + public function the_index_shows_only_what_the_user_may_do(): void + { + Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + + $this->actingAs($this->admin(['view_roles']), 'web_admin') + ->get('/admin/roles') + ->assertOk() + ->assertSee('editor') + ->assertDontSee('New role') + ->assertDontSee('/edit', false) + ->assertDontSee('data-confirm', false); + } + + #[Test] + public function a_search_without_results_says_so(): void + { + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles?search=' . urlencode('ނެތް')) + ->assertOk() + ->assertSee("Roles matching 'ނެތް'") + ->assertSee('No roles match your search.'); + } + + #[Test] + public function the_create_page_has_the_fields_and_the_permission_groups(): void + { + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles/create') + ->assertOk() + ->assertSee('New role') + ->assertSee('action="' . url('/admin/roles') . '"', false) + ->assertSee('name="name"', false) + ->assertSee('name="description"', false) + ->assertSee('data-permission-group="roles"', false) + ->assertSee('data-permission-group="posts"', false) + ->assertSee('data-permission-group-select="all"', false) + ->assertSee('Edit posts') + ->assertSee('Create role'); + + Manifest::assertAssetRequired('permissions::roles'); + } + + #[Test] + public function the_edit_page_ticks_the_granted_permissions(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + $role->givePermissionTo('edit_posts'); + + $granted = Permission::findByName('edit_posts', 'web_admin'); + $other = Permission::findByName('delete_posts', 'web_admin'); + + $content = $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}/edit") + ->assertOk() + ->assertSee('value="editor"', false) + ->assertSee('value="Editor"', false) + ->assertSee('name="_method" value="PUT"', false) + ->assertSee('Save') + ->getContent(); + + $this->assertMatchesRegularExpression('/]*id="permission-' . $granted->id . '"[^>]*checked/', $content); + $this->assertDoesNotMatchRegularExpression('/]*id="permission-' . $other->id . '"[^>]*checked/', $content); + } + + #[Test] + public function a_failed_update_keeps_the_ticked_permissions(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + $permission = Permission::findByName('delete_posts', 'web_admin'); + + $this->actingAs($this->admin(), 'web_admin') + ->from("/admin/roles/{$role->id}/edit") + ->put("/admin/roles/{$role->id}", ['name' => 'ނަން', 'description' => 'Editor', 'permissions' => [$permission->id]]) + ->assertRedirect("/admin/roles/{$role->id}/edit") + ->assertSessionHasErrors('name'); + + $content = $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}/edit") + ->assertSee('value="ނަން"', false) + ->getContent(); + + $this->assertMatchesRegularExpression('/]*id="permission-' . $permission->id . '"[^>]*checked/', $content); + $this->assertEmpty($role->fresh()->permissions); + } + + #[Test] + public function the_show_page_lists_the_granted_permissions(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + $role->givePermissionTo('edit_posts'); + + $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}") + ->assertOk() + ->assertSee('Edit posts') + ->assertDontSee('Delete posts') + ->assertSee('web_admin') + ->assertSee('Back to roles'); + + $empty = Role::query()->create(['name' => 'viewer', 'description' => 'Viewer', 'guard_name' => 'web_admin']); + + $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$empty->id}") + ->assertSee('This role has no permissions.'); + } + + #[Test] + public function the_pages_are_in_dhivehi_and_right_to_left(): void + { + __('Roles', locale: 'dv'); + $this->app['translator']->addLines(['*.Posts' => 'ޕޯސްޓްތައް', '*.Edit posts' => 'ޕޯސްޓްތައް ބަދަލުކުރުން'], 'dv'); + $this->app->setLocale('dv'); + + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles') + ->assertSee('dir="rtl"', false) + ->assertSee('ރޯލްތައް') + ->assertSee('އައު ރޯލެއް') + ->assertSee('ރޯލްތައް ހޯދާ'); + + $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}/edit") + ->assertSee('ޕޯސްޓްތައް') + ->assertSee('ޕޯސްޓްތައް ބަދަލުކުރުން') + ->assertSee('ހުރިހާ އެއްޗެއް ޚިޔާރުކުރޭ') + ->assertSee('ސޭވްކުރޭ'); + } + + #[Test] + public function the_pages_use_no_physical_direction_classes(): void + { + foreach (glob(__DIR__ . '/../../resources/views/roles/{,partials/}*.blade.php', GLOB_BRACE) as $view) { + $this->assertDoesNotMatchRegularExpression( + '/\b(ml|mr|pl|pr|left|right|text-left|text-right|border-l|border-r|rounded-l|rounded-r)-/', + file_get_contents($view), + basename($view) . ' should use logical utilities (ms-, pe-, start-, text-start).', + ); + } + } + + #[Test] + public function the_pages_render_with_bootstrap_too(): void + { + config()->set('forms.framework', 'bootstrap-6'); + + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + + $this->actingAs($this->admin(), 'web_admin')->get('/admin/roles')->assertOk()->assertSee('editor'); + $this->actingAs($this->admin(), 'web_admin')->get("/admin/roles/{$role->id}/edit")->assertOk()->assertSee('Edit posts'); + $this->actingAs($this->admin(), 'web_admin')->get("/admin/roles/{$role->id}")->assertOk(); + } +} diff --git a/tests/Http/InertiaPagesTest.php b/tests/Http/InertiaPagesTest.php new file mode 100644 index 0000000..55ba2ec --- /dev/null +++ b/tests/Http/InertiaPagesTest.php @@ -0,0 +1,117 @@ +set('permissions.guards', ['web', 'web_admin']); + $app['config']->set('permissions.views', 'inertia'); + $app['config']->set('inertia.testing.ensure_pages_exist', false); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpRoleRoutes(); + } + + #[Test] + public function the_index_gets_the_roles_links_abilities_and_dhivehi_strings(): void + { + __('Roles', locale: 'dv'); + app()->setLocale('dv'); + + $role = Role::query()->create(['name' => 'editor', 'description' => 'ލިޔުންތައް ބަދަލުކުރާ މީހުން', 'guard_name' => 'web_admin']); + $role->givePermissionTo('edit_posts'); + + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('permissions::Roles/Index') + ->where('title', 'ރޯލްތައް') + ->where('guard', 'web_admin') + ->where('roles.data.0.name', 'editor') + ->where('roles.data.0.permissions_count', 1) + ->where('roles.data.0.can.update', true) + ->where('roles.data.0.links.edit', url("/admin/roles/{$role->id}/edit")) + ->where('urls.create', url('/admin/roles/create')) + ->where('can.create', true) + ->where('strings.new_role', 'އައު ރޯލެއް') + ->where('strings.permissions_count', ':count ހުއްދަ') + ->where('search', null) + ->has('roles.meta.last_page')); + } + + #[Test] + public function the_create_page_gets_the_permission_groups(): void + { + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles/create') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('permissions::Roles/Create') + ->where('role', null) + ->where('translations', null) + ->where('urls.store', url('/admin/roles')) + ->where('permission_groups.1.label', 'Posts') + ->where('permission_groups.1.permissions.1.description', 'Edit posts') + ->where('permission_groups.1.permissions.1.granted', false)); + } + + #[Test] + public function the_edit_and_show_pages_get_the_role(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + $role->givePermissionTo('view_posts'); + + $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}/edit") + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('permissions::Roles/Edit') + ->where('role.name', 'editor') + ->where('urls.update', url("/admin/roles/{$role->id}")) + ->where('can.delete', true) + ->where('permission_groups.1.permissions.0.granted', true)); + + $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}") + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('permissions::Roles/Show') + ->where('role.permissions.0.name', 'view_posts') + ->has('permission_groups', 1) + ->where('strings.no_permissions', 'This role has no permissions.')); + } + + #[Test] + public function json_requests_still_get_json(): void + { + $this->actingAs($this->admin(), 'web_admin') + ->getJson('/admin/roles') + ->assertOk() + ->assertJsonPath('data.0.name', 'super_admin') + ->assertJsonMissingPath('strings'); + } +} diff --git a/tests/Http/InteractsWithRoleRoutes.php b/tests/Http/InteractsWithRoleRoutes.php new file mode 100644 index 0000000..038e4e9 --- /dev/null +++ b/tests/Http/InteractsWithRoleRoutes.php @@ -0,0 +1,47 @@ +prefix('admin')->name('admin.')->group(function () { + Route::roles(['guard' => 'web_admin']); + }); + } + + protected function setUpRoleRoutes(): void + { + View::addLocation(__DIR__ . '/../views'); + Manifest::fake(); + + Permissions::resource('posts', label: 'Posts'); + Permissions::sync(); + } + + protected function admin(array $permissions = []): Admin + { + $admin = Admin::query()->firstOrCreate( + ['email' => 'admin@example.com'], + ['name' => 'އެޑްމިން'], + ); + + if ($permissions) { + $admin->givePermissionTo($permissions); + } else { + $admin->assignRole('super_admin'); + } + + return $admin; + } +} diff --git a/tests/Http/RolesControllerTest.php b/tests/Http/RolesControllerTest.php new file mode 100644 index 0000000..63ef9f3 --- /dev/null +++ b/tests/Http/RolesControllerTest.php @@ -0,0 +1,448 @@ +set('permissions.guards', ['web', 'web_admin']); + $app['config']->set('inertia.testing.ensure_pages_exist', false); + } + + protected function defineRoutes($router): void + { + Route::middleware('web')->prefix('admin')->name('admin.')->group(function () { + Route::roles(['guard' => 'web_admin']); + }); + + Route::middleware('web')->group(function () { + Route::roles(['guard' => 'web', 'prefix' => 'account/roles', 'name' => 'account.roles']); + }); + } + + protected function setUp(): void + { + parent::setUp(); + + View::addLocation(__DIR__ . '/../views'); + Manifest::fake(); + + Permissions::resource('posts', label: 'Posts'); + Permissions::sync(); + } + + #[Test] + public function the_macro_registers_named_routes_under_the_apps_prefix(): void + { + $this->assertEquals('http://localhost/admin/roles/5/edit', route('admin.roles.edit', 5)); + $this->assertEquals('http://localhost/account/roles', route('account.roles.index')); + $this->assertEquals( + ['web', 'auth:web_admin'], + Route::getRoutes()->getByName('admin.roles.index')->gatherMiddleware(), + ); + } + + #[Test] + public function it_lists_only_the_roles_of_the_routes_guard_as_json(): void + { + Role::query()->create(['name' => 'editor', 'description' => 'ލިޔުންތައް ބަދަލުކުރާ މީހުން', 'guard_name' => 'web_admin']); + Role::query()->create(['name' => 'member', 'guard_name' => 'web']); + + $this->actingAs($this->admin(), 'web_admin') + ->getJson('/admin/roles') + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.name', 'editor') + ->assertJsonPath('data.0.description', 'ލިޔުންތައް ބަދަލުކުރާ މީހުން') + ->assertJsonPath('data.1.name', 'super_admin') + ->assertJsonPath('meta.total', 2); + } + + #[Test] + public function it_searches_roles(): void + { + Role::query()->create(['name' => 'editor', 'description' => 'ލިޔުންތައް ބަދަލުކުރާ މީހުން', 'guard_name' => 'web_admin']); + + $this->actingAs($this->admin(), 'web_admin') + ->getJson('/admin/roles?search=' . urlencode('ލިޔުން')) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.name', 'editor'); + } + + #[Test] + public function it_renders_the_blade_index(): void + { + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles') + ->assertOk() + ->assertSee('Roles') + ->assertSee('Super Admin') + ->assertSee(url('/admin/roles/create'), false); + } + + #[Test] + public function it_renders_inertia_pages_when_configured(): void + { + config()->set('permissions.views', 'inertia'); + + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('permissions::Roles/Index') + ->where('guard', 'web_admin') + ->where('routes.edit', 'admin.roles.edit') + ->has('roles.data', 1) + ->where('roles.data.0.name', 'super_admin')); + + $role = Role::findByName('super_admin', 'web_admin'); + + $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}/edit") + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('permissions::Roles/Edit') + ->where('role.name', 'super_admin') + ->where('permission_groups.0.name', 'roles') + ->where('permission_groups.0.permissions.0.name', 'view_roles') + ->where('permission_groups.0.permissions.0.granted', true) + ->where('permission_groups.1.label', 'Posts')); + } + + #[Test] + public function it_shows_the_form_data_for_a_new_role(): void + { + $this->actingAs($this->admin(), 'web_admin') + ->getJson('/admin/roles/create') + ->assertOk() + ->assertJsonPath('data.role', null) + ->assertJsonPath('data.guard', 'web_admin') + ->assertJsonPath('data.permission_groups.0.name', 'roles') + ->assertJsonPath('data.permission_groups.0.label', 'Roles') + ->assertJsonPath('data.permission_groups.0.permissions.0.granted', false) + ->assertJsonPath('data.permission_groups.1.name', 'posts'); + } + + #[Test] + public function the_edit_page_marks_granted_permissions_in_dhivehi(): void + { + __('Roles', locale: 'dv'); + $this->app['translator']->addLines(['*.Edit posts' => 'ޕޯސްޓްތައް ބަދަލުކުރުން'], 'dv'); + $this->app->setLocale('dv'); + + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + $role->givePermissionTo('edit_posts'); + + $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}/edit") + ->assertOk() + ->assertSee('ޕޯސްޓްތައް ބަދަލުކުރުން') + ->assertSee('ރޯލްތައް'); + } + + #[Test] + public function it_creates_a_role_for_the_routes_guard_with_permissions(): void + { + $admin = $this->admin(); + $permission = Permission::findByName('edit_posts', 'web_admin'); + + $this->actingAs($admin, 'web_admin') + ->postJson('/admin/roles', [ + 'name' => 'editor', + 'description' => 'ލިޔުންތައް ބަދަލުކުރާ މީހުން', + 'permissions' => [$permission->id], + ]) + ->assertCreated() + ->assertJsonPath('data.guard_name', 'web_admin') + ->assertJsonPath('data.permissions.0.name', 'edit_posts'); + + $role = Role::findByName('editor', 'web_admin'); + + $this->assertDatabaseHas('roles', ['name' => 'editor', 'guard_name' => 'web_admin', 'description' => 'ލިޔުންތައް ބަދަލުކުރާ މީހުން']); + $this->assertDatabaseMissing('roles', ['name' => 'editor', 'guard_name' => 'web']); + $this->assertTrue($role->hasPermissionTo('edit_posts')); + + $this->assertDatabaseHas('activity_log', [ + 'description' => 'permissions_updated', + 'subject_id' => $role->id, + 'causer_type' => $admin->getMorphClass(), + 'causer_id' => $admin->id, + ]); + } + + #[Test] + public function storing_from_a_form_redirects_to_the_edit_page_with_a_message(): void + { + $response = $this->actingAs($this->admin(), 'web_admin') + ->post('/admin/roles', ['name' => 'editor', 'description' => 'Editor']); + + $role = Role::findByName('editor', 'web_admin'); + + $response->assertRedirect(route('admin.roles.edit', $role)) + ->assertSessionHas('success', 'Role created'); + } + + #[Test] + public function it_validates_roles(): void + { + Role::query()->create(['name' => 'editor', 'guard_name' => 'web_admin']); + Role::query()->create(['name' => 'member', 'guard_name' => 'web']); + + $web_permission = Permission::findByName('edit_posts', 'web'); + + $this->actingAs($this->admin(), 'web_admin') + ->postJson('/admin/roles', [ + 'name' => 'editor', + 'permissions' => [$web_permission->id], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'description', 'permissions.0']); + + $this->actingAs($this->admin(), 'web_admin') + ->postJson('/admin/roles', ['name' => 'ނަން', 'description' => 'Editor']) + ->assertJsonValidationErrors(['name']); + + $this->actingAs($this->admin(), 'web_admin') + ->postJson('/admin/roles', ['name' => 'member', 'description' => 'Member']) + ->assertCreated(); + } + + #[Test] + public function it_updates_a_role_and_its_permissions(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + $role->givePermissionTo('view_posts'); + + $permission = Permission::findByName('edit_posts', 'web_admin'); + + $this->actingAs($this->admin(), 'web_admin') + ->putJson("/admin/roles/{$role->id}", [ + 'name' => 'editor', + 'description' => 'Content editor', + 'permissions' => [$permission->id], + ]) + ->assertOk() + ->assertJsonPath('data.description', 'Content editor'); + + $role->refresh(); + + $this->assertEquals(['edit_posts'], $role->permissions->pluck('name')->all()); + } + + #[Test] + public function a_json_update_without_permissions_keeps_them(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + $role->givePermissionTo('view_posts'); + + $this->actingAs($this->admin(), 'web_admin') + ->patchJson("/admin/roles/{$role->id}", ['description' => 'ބަދަލުކުރާ މީހުން']) + ->assertOk() + ->assertJsonPath('data.name', 'editor'); + + $this->assertEquals(['view_posts'], $role->fresh()->permissions->pluck('name')->all()); + } + + #[Test] + public function a_form_update_with_no_permissions_ticked_removes_them(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editor', 'guard_name' => 'web_admin']); + $role->givePermissionTo('view_posts'); + + $this->actingAs($this->admin(), 'web_admin') + ->put("/admin/roles/{$role->id}", ['name' => 'editor', 'description' => 'Editor']) + ->assertRedirect(route('admin.roles.edit', $role)) + ->assertSessionHas('success', 'Role updated'); + + $this->assertEmpty($role->fresh()->permissions); + } + + #[Test] + public function it_shows_a_role(): void + { + $role = Role::findByName('super_admin', 'web_admin'); + + $this->actingAs($this->admin(), 'web_admin') + ->getJson("/admin/roles/{$role->id}") + ->assertOk() + ->assertJsonPath('data.name', 'super_admin'); + + $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}") + ->assertOk() + ->assertSee('super_admin') + ->assertSee('View roles'); + } + + #[Test] + public function it_deletes_a_role(): void + { + $role = Role::query()->create(['name' => 'editor', 'guard_name' => 'web_admin']); + $other = Role::query()->create(['name' => 'author', 'guard_name' => 'web_admin']); + + $this->actingAs($this->admin(), 'web_admin') + ->deleteJson("/admin/roles/{$role->id}") + ->assertNoContent(); + + $this->actingAs($this->admin(), 'web_admin') + ->delete("/admin/roles/{$other->id}") + ->assertRedirect(route('admin.roles.index')) + ->assertSessionHas('success', 'Role deleted'); + + $this->assertDatabaseMissing('roles', ['id' => $role->id]); + $this->assertDatabaseMissing('roles', ['id' => $other->id]); + } + + #[Test] + public function roles_of_another_guard_are_not_found(): void + { + $role = Role::query()->create(['name' => 'member', 'guard_name' => 'web']); + + $this->actingAs($this->admin(), 'web_admin') + ->getJson("/admin/roles/{$role->id}") + ->assertNotFound(); + + $this->actingAs($this->admin(), 'web_admin') + ->deleteJson("/admin/roles/{$role->id}") + ->assertNotFound(); + } + + #[Test] + public function it_needs_the_role_permissions(): void + { + $admin = Admin::query()->create(['name' => 'ޢަލީ', 'email' => 'ali@example.com']); + $role = Role::findByName('super_admin', 'web_admin'); + + $this->actingAs($admin, 'web_admin')->getJson('/admin/roles')->assertForbidden(); + $this->actingAs($admin, 'web_admin')->getJson('/admin/roles/create')->assertForbidden(); + $this->actingAs($admin, 'web_admin')->postJson('/admin/roles', ['name' => 'x', 'description' => 'x'])->assertForbidden(); + $this->actingAs($admin, 'web_admin')->getJson("/admin/roles/{$role->id}")->assertForbidden(); + $this->actingAs($admin, 'web_admin')->getJson("/admin/roles/{$role->id}/edit")->assertForbidden(); + $this->actingAs($admin, 'web_admin')->putJson("/admin/roles/{$role->id}", [])->assertForbidden(); + $this->actingAs($admin, 'web_admin')->deleteJson("/admin/roles/{$role->id}")->assertForbidden(); + + $admin->givePermissionTo('view_roles'); + + $this->actingAs($admin, 'web_admin')->getJson('/admin/roles')->assertOk(); + $this->actingAs($admin, 'web_admin')->getJson("/admin/roles/{$role->id}")->assertOk(); + $this->actingAs($admin, 'web_admin')->getJson("/admin/roles/{$role->id}/edit")->assertForbidden(); + } + + #[Test] + public function each_guard_manages_its_own_roles(): void + { + $user = User::query()->create(['name' => 'މަރިޔަމް', 'email' => 'mariyam@example.com']); + + $this->getJson('/admin/roles')->assertUnauthorized(); + $this->actingAs($user, 'web')->getJson('/admin/roles')->assertUnauthorized(); + $this->actingAs($user, 'web')->getJson('/account/roles')->assertForbidden(); + + $user->assignRole('super_admin'); + + $this->actingAs($user, 'web') + ->getJson('/account/roles') + ->assertOk() + ->assertJsonPath('data.0.guard_name', 'web'); + + $this->actingAs($user, 'web') + ->postJson('/account/roles', ['name' => 'member', 'description' => 'Member']) + ->assertCreated() + ->assertJsonPath('data.guard_name', 'web'); + } + + #[Test] + public function the_controller_can_be_overridden_from_config(): void + { + config()->set('permissions.controllers.roles', CustomRolesController::class); + + Route::roles(['guard' => 'web_admin', 'prefix' => 'custom', 'name' => 'custom']); + + $this->actingAs($this->admin(), 'web_admin') + ->getJson('/custom') + ->assertOk() + ->assertJson(['custom' => true]); + } + + #[Test] + public function the_controller_can_be_overridden_per_route(): void + { + Route::roles(['guard' => 'web_admin', 'prefix' => 'other', 'name' => 'other', 'controller' => CustomRolesController::class, 'middleware' => []]); + + $this->getJson('/other')->assertOk()->assertJson(['custom' => true]); + } + + #[Test] + public function the_role_policy_can_be_overridden_from_config(): void + { + config()->set('permissions.policies', [Role::class => DenyAllPolicy::class]); + Permissions::applyPolicies(); + + $this->actingAs($this->admin(), 'web_admin') + ->getJson('/admin/roles') + ->assertForbidden(); + } + + #[Test] + public function the_role_events_fire_with_the_admin_as_causer(): void + { + Event::fake([RolePermissionsUpdated::class]); + + $admin = $this->admin(); + $permission = Permission::findByName('view_posts', 'web_admin'); + + $this->actingAs($admin, 'web_admin') + ->postJson('/admin/roles', ['name' => 'viewer', 'description' => 'Viewer', 'permissions' => [$permission->id]]) + ->assertCreated(); + + Event::assertDispatched( + RolePermissionsUpdated::class, + fn (RolePermissionsUpdated $event) => $event->causer->is($admin) && $event->new_permissions === ['view_posts'], + ); + } + + protected function admin(): Admin + { + $admin = Admin::query()->firstOrCreate( + ['email' => 'admin@example.com'], + ['name' => 'އެޑްމިން'], + ); + + $admin->assignRole('super_admin'); + + return $admin; + } +} diff --git a/tests/InteractsWithDatabase.php b/tests/InteractsWithDatabase.php index 34e312e..3a6b9a6 100644 --- a/tests/InteractsWithDatabase.php +++ b/tests/InteractsWithDatabase.php @@ -8,10 +8,8 @@ trait InteractsWithDatabase { use RefreshDatabase; - protected function runMigrations() + protected function defineDatabaseMigrations(): void { - include_once __DIR__ . '/database/create_users_table.php'; - - (new \CreateUsersTable)->up(); + $this->loadMigrationsFrom(__DIR__ . '/database'); } } diff --git a/tests/Models/Admin.php b/tests/Models/Admin.php new file mode 100644 index 0000000..158ec04 --- /dev/null +++ b/tests/Models/Admin.php @@ -0,0 +1,13 @@ +morphTo(); + } + + public function isOwnedBy(Model $user): bool + { + return $this->author_type === $user->getMorphClass() + && (int) $this->author_id === (int) $user->getKey(); + } +} diff --git a/tests/Models/User.php b/tests/Models/User.php index 56bb4e6..5c4103a 100644 --- a/tests/Models/User.php +++ b/tests/Models/User.php @@ -3,15 +3,11 @@ namespace Javaabu\Permissions\Tests\Models; use Illuminate\Foundation\Auth\User as Authenticatable; -use Javaabu\Activitylog\Traits\LogsActivity; use Javaabu\Permissions\Traits\HasRoles; -use Spatie\Activitylog\Traits\CausesActivity; class User extends Authenticatable { use HasRoles; - use LogsActivity; - use CausesActivity; protected $guarded = []; } diff --git a/tests/Policies/AdminPostPolicy.php b/tests/Policies/AdminPostPolicy.php new file mode 100644 index 0000000..09073b2 --- /dev/null +++ b/tests/Policies/AdminPostPolicy.php @@ -0,0 +1,12 @@ + 'publish_posts', + 'review' => ['approve_posts', 'publish_posts'], + ]; +} diff --git a/tests/TestCase.php b/tests/TestCase.php index ff8aa93..002bc2a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,56 +2,87 @@ namespace Javaabu\Permissions\Tests; -use Javaabu\Activitylog\ActivitylogServiceProvider; -use Javaabu\Activitylog\Models\Activity; -use Javaabu\Helpers\HelpersServiceProvider; +use Illuminate\Support\Collection; +use Javaabu\Forms\FormsServiceProvider; +use Javaabu\Manifest\ManifestServiceProvider; use Javaabu\Permissions\Models\Permission; use Javaabu\Permissions\Models\Role; +use Javaabu\Permissions\PermissionsServiceProvider; +use Javaabu\Permissions\Registry\PermissionRegistry; +use Javaabu\Permissions\Tests\Models\Admin; use Javaabu\Permissions\Tests\Models\User; use Orchestra\Testbench\TestCase as BaseTestCase; -use Javaabu\Permissions\PermissionsServiceProvider; +use Spatie\Activitylog\ActivitylogServiceProvider; +use Spatie\Activitylog\Models\Activity; +use Spatie\Permission\PermissionServiceProvider; abstract class TestCase extends BaseTestCase { + /** Start each test with nothing registered */ + protected bool $flush_registry = true; - public function setUp(): void + protected function setUp(): void { parent::setUp(); - $this->app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); - - $this->app['config']->set('session.serialization', 'php'); - - $this->app['config']->set('permission.models.permission', Permission::class); - - $this->app['config']->set('permission.models.role', Role::class); + if ($this->flush_registry) { + $this->app->make(PermissionRegistry::class)->flush(); + } + } - $this->app['config']->set('activitylog.activity_model', Activity::class); + protected function defineEnvironment($app): void + { + $app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + $app['config']->set('session.serialization', 'php'); + $app['config']->set('permission.models.permission', Permission::class); + $app['config']->set('permission.models.role', Role::class); - $this->app['config']->set('auth.guards', [ + $app['config']->set('auth.guards', [ 'web' => [ 'driver' => 'session', - 'provider' => 'users' - ] + 'provider' => 'users', + ], + 'web_admin' => [ + 'driver' => 'session', + 'provider' => 'admins', + ], ]); - $this->app['config']->set('auth.providers', [ + $app['config']->set('auth.providers', [ 'users' => [ 'driver' => 'eloquent', 'model' => User::class, - ] + ], + 'admins' => [ + 'driver' => 'eloquent', + 'model' => Admin::class, + ], ]); - } - protected function getPackageProviders($app) + protected function getPackageProviders($app): array { return [ - HelpersServiceProvider::class, - \Spatie\Activitylog\ActivitylogServiceProvider::class, ActivitylogServiceProvider::class, - \Spatie\Permission\PermissionServiceProvider::class, - PermissionsServiceProvider::class + PermissionServiceProvider::class, + PermissionsServiceProvider::class, + ManifestServiceProvider::class, + FormsServiceProvider::class, ]; } + + /** + * The logged before and after values: `attribute_changes` on spatie/laravel-activitylog v5, + * `properties` on v4 + */ + protected function loggedChanges(Activity $log): Collection + { + if (method_exists($log, 'changes')) { + return $log->changes(); + } + + $this->assertNull($log->properties?->get('attributes'), 'v5 keeps the changes out of properties'); + + return $log->attribute_changes; + } } diff --git a/tests/Translatable/TranslatableRolesTest.php b/tests/Translatable/TranslatableRolesTest.php new file mode 100644 index 0000000..a67aad3 --- /dev/null +++ b/tests/Translatable/TranslatableRolesTest.php @@ -0,0 +1,142 @@ +set('permission.models.role', Role::class); + $app['config']->set('permissions.guards', ['web', 'web_admin']); + $app['config']->set('inertia.testing.ensure_pages_exist', false); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpLanguages(['en', 'dv']); + $this->setUpRoleRoutes(); + } + + #[Test] + public function it_creates_a_role_with_a_description_in_each_language(): void + { + $this->actingAs($this->admin(), 'web_admin') + ->postJson('/admin/roles', [ + 'name' => 'editor', + 'description_en' => 'Editors', + 'description_dv' => 'ލިޔުންތައް ބަދަލުކުރާ މީހުން', + ]) + ->assertCreated() + ->assertJsonPath('data.description', 'Editors') + ->assertJsonPath('data.translations.description_dv', 'ލިޔުންތައް ބަދަލުކުރާ މީހުން'); + + $role = Role::findByName('editor', 'web_admin'); + + $this->assertInstanceOf(Role::class, $role); + $this->assertEquals('ލިޔުންތައް ބަދަލުކުރާ މީހުން', $role->translate('description', 'dv', false)); + + app()->setLocale('dv'); + Languages::setCurrentLocale('dv'); + + $this->assertEquals('ލިޔުންތައް ބަދަލުކުރާ މީހުން', $role->fresh()->translated_description); + } + + #[Test] + public function the_primary_description_is_required_and_the_others_are_not(): void + { + $this->actingAs($this->admin(), 'web_admin') + ->postJson('/admin/roles', ['name' => 'editor', 'description_dv' => 'ލިޔުންތައް ބަދަލުކުރާ މީހުން']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['description_en']) + ->assertJsonMissingValidationErrors(['description_dv', 'description']); + + $this->actingAs($this->admin(), 'web_admin') + ->postJson('/admin/roles', ['name' => 'editor', 'description_en' => 'Editors']) + ->assertCreated(); + } + + #[Test] + public function the_blade_form_has_an_input_per_language(): void + { + $role = Role::query()->create(['name' => 'editor', 'description' => 'Editors', 'guard_name' => 'web_admin']); + $role->addTranslation('dv', 'description', 'ލިޔުންތައް ބަދަލުކުރާ މީހުން')->save(); + + $content = $this->actingAs($this->admin(), 'web_admin') + ->get("/admin/roles/{$role->id}/edit") + ->assertOk() + ->assertSee('name="description_en"', false) + ->assertSee('name="description_dv"', false) + ->assertSee('ލިޔުންތައް ބަދަލުކުރާ މީހުން') + ->assertDontSee('name="description"', false) + ->getContent(); + + $this->assertMatchesRegularExpression('/]*name="description_dv"[^>]*dir="rtl"|]*dir="rtl"[^>]*name="description_dv"/', $content); + } + + #[Test] + public function the_inertia_form_gets_the_inputs_and_values(): void + { + config()->set('permissions.views', 'inertia'); + + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles/create') + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('permissions::Roles/Create') + ->has('translations.inputs.description', 2) + ->where('translations.inputs.description.0.name', 'description_en') + ->where('translations.inputs.description.0.primary', true) + ->where('translations.inputs.description.1.name', 'description_dv') + ->where('translations.inputs.description.1.thaana', true) + ->where('translations.inputs.description.1.direction', 'rtl') + ->where('translations.values', [])); + } + + #[Test] + public function it_uses_the_plain_description_with_translatable_switched_off(): void + { + $this->withoutTranslatable(function () { + $this->assertFalse(Translations::supportsRole()); + + $this->actingAs($this->admin(), 'web_admin') + ->postJson('/admin/roles', ['name' => 'viewer', 'description' => 'ބަލާ މީހުން']) + ->assertCreated() + ->assertJsonPath('data.description', 'ބަލާ މީހުން') + ->assertJsonMissingPath('data.translations'); + + $this->actingAs($this->admin(), 'web_admin') + ->get('/admin/roles/create') + ->assertOk() + ->assertSee('name="description"', false) + ->assertDontSee('name="description_dv"', false); + }); + } +} 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..9759ce0 --- /dev/null +++ b/tests/database/2024_01_01_000000_create_test_tables.php @@ -0,0 +1,54 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); + }); + + Schema::create('admins', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->nullableMorphs('author'); + $table->timestamps(); + }); + + Schema::create('activity_log', function (Blueprint $table) { + $table->id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + // spatie/laravel-activitylog v5; v4 doesn't use it + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + $table->uuid('batch_uuid')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_log'); + Schema::dropIfExists('posts'); + Schema::dropIfExists('admins'); + Schema::dropIfExists('users'); + } +}; diff --git a/tests/database/create_users_table.php b/tests/database/create_users_table.php deleted file mode 100644 index 9d533d8..0000000 --- a/tests/database/create_users_table.php +++ /dev/null @@ -1,32 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamps(); - }); - } - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('users'); - } -} diff --git a/tests/js/components.test.tsx b/tests/js/components.test.tsx new file mode 100644 index 0000000..d0a8892 --- /dev/null +++ b/tests/js/components.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PermissionGroupFields } from "../../resources/js/components/PermissionGroupFields"; +import { RoleActions } from "../../resources/js/components/RoleActions"; +import { RoleForm } from "../../resources/js/components/RoleForm"; +import { groups, makeRole, strings } from "./fixtures"; + +/** Ids of the ticked permission checkboxes (native inputs without an adapter) */ +function checked(): string[] { + return Array.from(document.querySelectorAll('input[type="checkbox"][name="permissions[]"]')) + .filter((box) => box.checked) + .map((box) => box.id); +} + +describe("PermissionGroupFields", () => { + it("starts with the granted permissions and selects a group at once", () => { + render(); + + expect(screen.getByText("ޕޯސްޓްތައް")).toBeTruthy(); + expect(checked()).toEqual(["permission-1"]); + + fireEvent.click(screen.getAllByText(strings.select_all)[0]!); + expect(checked()).toEqual(["permission-1", "permission-2"]); + + fireEvent.click(screen.getAllByText(strings.select_none)[0]!); + expect(checked()).toEqual([]); + }); +}); + +describe("RoleForm", () => { + it("renders one description field per locale when descriptions are translated", () => { + render( + , + ); + + const dhivehi = document.querySelector('input[name="description_dv"]')!; + + expect(dhivehi.value).toBe("ލިޔުންތައް ބަދަލުކުރާ މީހުން"); + expect(dhivehi.getAttribute("dir")).toBe("rtl"); + expect(document.querySelector('input[name="description_en"]')!.value).toBe("Editors"); + expect(document.querySelector('input[name="description"]')).toBeNull(); + expect(document.querySelector('input[name="_method"]')!.value).toBe("PUT"); + expect(screen.getByText(strings.save)).toBeTruthy(); + }); + + it("renders a plain description field otherwise", () => { + render(); + + expect(document.querySelector('input[name="description"]')).toBeTruthy(); + expect(screen.getByText(strings.create)).toBeTruthy(); + }); +}); + +describe("RoleActions", () => { + afterEach(() => vi.restoreAllMocks()); + + it("shows the buttons the user may use and confirms deleting", () => { + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + + expect(screen.queryByText(strings.edit)).toBeNull(); + + fireEvent.click(screen.getByText(strings.delete)); + expect(confirm).toHaveBeenCalledWith(strings.confirm_delete); + }); +}); diff --git a/tests/js/fixtures.ts b/tests/js/fixtures.ts new file mode 100644 index 0000000..c27ef27 --- /dev/null +++ b/tests/js/fixtures.ts @@ -0,0 +1,60 @@ +import type { PermissionGroup, RoleData, RolesStrings } from "../../resources/js/types"; + +export const strings: RolesStrings = { + roles: "ރޯލްތައް", + new_role: "އައު ރޯލެއް", + search: "ރޯލްތައް ހޯދާ", + name: "ނަން", + name_help: "އިނގިރޭސި އަކުރު، ނަންބަރު، ޑޭޝް އަދި އަންޑަސްކޯ", + description: "ތަފްޞީލު", + permissions: "ހުއްދަތައް", + permissions_count: ":count ހުއްދަ", + actions: "ޢަމަލުތައް", + view: "ބަލާ", + edit: "ބަދަލުކުރޭ", + delete: "ފުހެލާ", + save: "ސޭވްކުރޭ", + create: "ރޯލް އުފައްދާ", + back: "ރޯލްތަކަށް އެނބުރި", + confirm_delete: "މި ރޯލް ފުހެލަންވީތޯ؟", + no_roles: "އަދި އެއްވެސް ރޯލެއް ނެތް", + no_roles_message: "ތިބާ އުފައްދާ ރޯލްތައް މިތަނުގައި ފެންނާނެ.", + no_results: "ހޯދި ގޮތާ ގުޅޭ އެއްވެސް ރޯލެއް ނުފެނުނު.", + select_all: "ހުރިހާ އެއްޗެއް ޚިޔާރުކުރޭ", + select_none: "އެއްވެސް އެއްޗެއް ޚިޔާރު ނުކުރާ", + no_permissions: "މި ރޯލަކަށް އެއްވެސް ހުއްދައެއް ނެތް.", + details: "މަޢުލޫމާތު", + guard: "ގާޑު", +}; + +export const groups: PermissionGroup[] = [ + { + name: "posts", + label: "ޕޯސްޓްތައް", + permissions: [ + { id: 1, name: "view_posts", description: "ޕޯސްޓްތައް ބެލުން", granted: true }, + { id: 2, name: "edit_posts", description: "ޕޯސްޓްތައް ބަދަލުކުރުން", granted: false }, + ], + }, + { + name: "roles", + label: "ރޯލްތައް", + permissions: [{ id: 3, name: "view_roles", description: "ރޯލްތައް ބެލުން", granted: false }], + }, +]; + +export function makeRole(overrides: Partial = {}): RoleData { + return { + id: 5, + name: "editor", + description: "ލިޔުންތައް ބަދަލުކުރާ މީހުން", + translated_description: "ލިޔުންތައް ބަދަލުކުރާ މީހުން", + guard_name: "web_admin", + permissions_count: 1, + can: { view: true, update: true, delete: true }, + links: { show: "/admin/roles/5", edit: "/admin/roles/5/edit", update: "/admin/roles/5", destroy: "/admin/roles/5" }, + created_at: null, + updated_at: null, + ...overrides, + }; +} diff --git a/tests/js/roles.test.ts b/tests/js/roles.test.ts new file mode 100644 index 0000000..9ae8b79 --- /dev/null +++ b/tests/js/roles.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { selectGroup } from "../../resources/js/roles"; + +function groupMarkup(): string { + return ` +
+
+ + + + + +
+
+ +
`; +} + +function boxes(): HTMLInputElement[] { + return Array.from(document.querySelectorAll('input[name="permissions[]"]')); +} + +describe("Blade role script", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("ticks and unticks every enabled permission of one group", () => { + document.body.innerHTML = groupMarkup(); + + selectGroup(document.querySelector("[data-permission-group=posts]")!, true); + expect(boxes().map((box) => box.checked)).toEqual([true, true, false, false]); + + selectGroup(document.querySelector("[data-permission-group=posts]")!, false); + expect(boxes().map((box) => box.checked)).toEqual([false, false, false, false]); + }); + + it("selects a group from its buttons", () => { + document.body.innerHTML = groupMarkup(); + + document.querySelector('[data-permission-group-select="all"]')!.click(); + expect(boxes().map((box) => box.checked)).toEqual([true, true, false, false]); + + document.querySelector('[data-permission-group-select="none"]')!.click(); + expect(boxes().map((box) => box.checked)).toEqual([false, false, false, false]); + }); + + it("asks before submitting a form with data-confirm", () => { + document.body.innerHTML = groupMarkup(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + const form = document.querySelector("form")!; + const event = new Event("submit", { bubbles: true, cancelable: true }); + + form.dispatchEvent(event); + + expect(confirm).toHaveBeenCalledWith("މި ރޯލް ފުހެލަންވީތޯ؟"); + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/tests/js/setup.ts b/tests/js/setup.ts new file mode 100644 index 0000000..3244451 --- /dev/null +++ b/tests/js/setup.ts @@ -0,0 +1,14 @@ +// 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/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/layouts/app.blade.php b/tests/views/layouts/app.blade.php new file mode 100644 index 0000000..c65bae9 --- /dev/null +++ b/tests/views/layouts/app.blade.php @@ -0,0 +1,7 @@ + + +{{ $title ?? '' }} + +@yield('content') + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..24eb2e8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "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/permissions": ["./resources/js"], + "@javaabu/permissions/*": ["./resources/js/*"] + } + }, + "include": ["resources/js", "tests/js"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..df33abf --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["tests/js/**/*.test.{ts,tsx}"], + setupFiles: ["tests/js/setup.ts"], + }, +});