diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results index e838ac2d..d3074d10 100644 --- a/.phpunit.cache/test-results +++ b/.phpunit.cache/test-results @@ -1 +1 @@ -{"version":"pest_2.33.1","defects":[],"times":{"P\\Tests\\DatabaseTest::__pest_evaluable_it_can_check_if_package_testing_is_configured":0,"P\\Tests\\DatabaseTest::__pest_evaluable_it_can_check_if_the_permission_name_can_be_configured_using_the_closure":0.005}} \ No newline at end of file +{"version":"pest_2.36.0","defects":[],"times":{"P\\Tests\\DatabaseTest::__pest_evaluable_it_can_check_if_the_permission_name_can_be_configured_using_the_closure":0.005,"P\\Tests\\DatabaseTest::__pest_evaluable_it_can_check_if_package_testing_is_configured":0}} \ No newline at end of file diff --git a/README.md b/README.md index e19caf09..af354fa7 100644 --- a/README.md +++ b/README.md @@ -23,81 +23,99 @@ # Shield -The easiest and most intuitive way to add access management to your Filament Admin: -- :fire: **Resources** -- :fire: **Pages** -- :fire: **Widgets** -- :fire: **Custom Permissions** +The easiest and most intuitive way to add access management to your Filament Panels. +## Features + +- ๐Ÿ›ก๏ธ **Complete Authorization Management** + - Resource Permissions + - Page Permissions + - Widget Permissions + - Custom Permissions +- ๐Ÿ”„ **Multi-tenancy Support** +- ๐Ÿš€ **Easy Setup & Configuration** +- ๐ŸŽจ **Best UI** +- ๐Ÿ“ฆ **Policy Generation** +- ๐ŸŒ **Translations Support** + +## Requirements + +- PHP 8.1 | 8.2 | 8.3 +- Laravel v10.x | v11.x +- Filament v3.2+ +- Spatie Permission v6.0+ > [!NOTE] > For **Filament 2.x** use **[2.x](https://github.com/bezhanSalleh/filament-shield/tree/2.x)** branch -> [!IMPORTANT] -> Prior to `v3.1.0` Shield supported [spatie/laravel-permission](https://packagist.org/packages/spatie/laravel-permission):`^5.0` and now it supports version `^6.0`. Which has some breaking changes around migrations. If you are upgrading from a version prior to `v3.1.0` please make sure to remove the old migration file and republish the new one. - ## Installation -1. Install the package via composer: - +### 1. Install Package ```bash composer require bezhansalleh/filament-shield ``` -2. Add the `Spatie\Permission\Traits\HasRoles` trait to your User model(s): - +### 2. Configure Auth Provider +Add the `HasRoles` trait to your User model: ```php -use Illuminate\Foundation\Auth\User as Authenticatable; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { use HasRoles; - - // ... } ``` -3. Publish the `config` file then setup your configuration: + +### 3. Setup Shield +3.1 **Without Tenancy:** ```bash -php artisan vendor:publish --tag=filament-shield-config +php artisan shield:setup ``` -4. Register the plugin for the Filament Panels you want -```php -public function panel(Panel $panel): Panel -{ - return $panel - ->plugins([ - \BezhanSalleh\FilamentShield\FilamentShieldPlugin::make() - ]); -} + +3.2 **With Tenancy:** +```bash +php artisan shield:setup --tenant=App\\Models\\Team +# Replace Team with your tenant model ``` -5. Now run the following command to install shield: + +This command will: +- Publish core package config +- Publish core package migrations +- Run initial migrations +- Publish shield config +- Configure tenancy if specified + +### 4. Install for Panel +The install command will register the plugin for your panel automatically. Choose the appropriate installation method: + +4.1 **Without Tenancy:** ```bash -php artisan shield:install +php artisan shield:install admin +# Replace 'admin' with your panel ID ``` -Follow the prompts and enjoy! -## Filament Panels -If you want to enable `Shield` for more than one panel then you need to register the plugin for each panel as mentioned above. +4.2 **With Tenancy:** +```bash +php artisan shield:install admin --tenant --generate-relationships +# Replace 'admin' with your panel ID +``` -### Panel Access -Shield comes with the `HasPanelShield` trait which provides an easy way to integrate Shield's conventions with the Filament's panel access system. +This command will: +- Register Shield plugin for your panel +- If `--tenant` flag is provided: + - Activates tenancy features + - Makes the panel tenantable + - Adds `SyncShieldTenant` middleware to the panel + - Configures tenant model from the config +- If `--generate-relationships` flag is provided: + - Generates required relationships between resource models and the tenant model + - Adds necessary relationship methods in both the resource and tenant models -The `HasPanelShield` trait provides an implementation for the `canAccessPanel` method, determining access based on whether the user possesses the `super_admin` role or the `panel_user` role. It also assigns the `panel_user` role to the user upon creation and removes it upon deletion. Ofcourse the role names can be changed from the plugin's configuration file. +## Usage -```php -use BezhanSalleh\FilamentShield\Traits\HasPanelShield; -use Filament\Models\Contracts\FilamentUser; -use Illuminate\Foundation\Auth\User as Authenticatable; -use Spatie\Permission\Traits\HasRoles; +#### Configuration +See [config/filament-shield.php](config/filament-shield.php) for full configuration options. -class User extends Authenticatable implements FilamentUser -{ - use HasRoles; - use HasPanelShield; - // ... -} -``` #### Resources Generally there are two scenarios that shield handles permissions for your `Filament` resources. @@ -330,47 +348,7 @@ class IncomeWidget extends LineChartWidget ### Policies -#### Role Policy -##### Using Laravel 10 -To ensure `RoleResource` access via `RolePolicy` you would need to add the following to your `AuthServiceProvider`: - -```php -//AuthServiceProvider.php -... -protected $policies = [ - 'Spatie\Permission\Models\Role' => 'App\Policies\RolePolicy', -]; -... -``` -##### Using Laravel 11 - -To ensure `RoleResource` access via `RolePolicy` you would need to add the following to your `AppServiceProvider`: - -```php -//AppServiceProvider.php -use Illuminate\Support\Facades\Gate; -... -public function boot(): void - { - ... - Gate::policy(\Spatie\Permission\Models\Role::class, \App\Policies\RolePolicy::class); - } -... -``` - -**whatever your version of Laravel, you can skip it if you have enabled it from the `config`:** - -```php -// config/filament-shield.php -... - -'register_role_policy' => [ - 'enabled' => true, -], -... -``` - -#### Policy Path +#### Path If your policies are not in the default `Policies` directory in the `app_path()` you can change the directory name in the config file: ```php @@ -399,6 +377,7 @@ class AuthServiceProvider extends ServiceProvider ]; ``` + ##### Using Laravel 11 ```php //AppServiceProvider.php @@ -467,6 +446,43 @@ public function panel(Panel $panel): Panel ``` Screenshot 2023-09-24 at 10 34 31 PM +## Available Commands + +### Core Commands +```bash +# Setup Shield +shield:setup [--fresh] [--minimal] [--tenant=] + +# Install Shield for a panel +shield:install {panel} [--tenant] [--generate-relationships] + +# Generate permissions/policies +shield:generate [options] + +# Create super admin +shield:super-admin [--user=] [--panel=] [--tenant=] + +# Create seeder +shield:seeder [options] + +# Publish Role Resource +shield:publish {panel} +``` + +### Generate Command Options +```bash +--all Generate for all entities +--option=[OPTION] Override generator option +--resource=[RESOURCE] Specific resources +--page=[PAGE] Specific pages +--widget=[WIDGET] Specific widgets +--exclude Exclude entities +--ignore-config-exclude Ignore config excludes +--panel[=PANEL] Panel ID to get the components(resources, pages, widgets) +``` +> [!NOTE] +> For setting up super-admin user when using tenancy/team feature consult the core package **[spatie/laravel-permission](https://spatie.be/docs/laravel-permission/v6/basic-usage/teams-permissions)** + #### Translations Publish the translations using: @@ -475,38 +491,6 @@ Publish the translations using: php artisan vendor:publish --tag="filament-shield-translations" ``` -## Available Filament Shield Commands - -#### `shield:doctor` -- Show useful info about Filament Shield. - -#### `shield:install` -Setup Core Package requirements and Install Shield. Accepts the following flags: -- `--fresh` re-run the migrations -- `--only` Only setups shield without generating permissions and creating super-admin - -#### `shield:generate` -Generate Permissions and/or Policies for Filament entities. Accepts the following flags: -- `--all` Generate permissions/policies for all entities -- `--option[=OPTION]` Override the config generator option(`policies_and_permissions`,`policies`,`permissions`) -- `--resource[=RESOURCE]` One or many resources separated by comma (,) -- `--page[=PAGE]` One or many pages separated by comma (,) -- `--widget[=WIDGET]` One or many widgets separated by comma (,) -- `--exclude` Exclude the given entities during generation -- `--ignore-config-exclude` Ignore config `exclude` option during generation -- `--ignore-existing-policies` Do not overwrite the existing policies. - -#### `shield:super-admin` -Create a user with super_admin role. -- Accepts an `--user=` argument that will use the provided ID to find the user to be made super admin. - -### `shield:publish` -- Publish the Shield `RoleResource` and customize it however you like - -### `shield:seeder` -- Deploy easily by setting up your roles and permissions or add your custom seeds - - ## Testing ```bash diff --git a/TODO b/TODO new file mode 100644 index 00000000..1ee7c6e9 --- /dev/null +++ b/TODO @@ -0,0 +1,11 @@ + +Todo: + โ˜ Accept auth model the same way as the tenant model and set it up + โ˜ Remove extra info from commands + โœ” Given all shield commands are destructive, add the ability to disable them in production @done(24-11-07 23:59) + โ˜ Make shield middleware publishable and handle if it is published + โ˜ Make use of the custom team foreign key + โ˜ checking for tables also check if team was enabled or should be enabled if already installed and the tenant flag provided + โœ” Remove/replace doctor command with about command @done(24-11-08 00:00) + โ˜ Move tenant relationship generation to the generate command + โ˜ should handle if already installed for a panel but central flag. for now central is always false \ No newline at end of file diff --git a/composer.json b/composer.json index 55fbedda..71ab0ad7 100644 --- a/composer.json +++ b/composer.json @@ -29,16 +29,17 @@ "spatie/laravel-permission": "^6.0" }, "require-dev": { + "larastan/larastan": "^2.0", "laravel/pint": "^1.0", "nunomaduro/collision": "^7.0|^8.0", - "larastan/larastan": "^2.0", "orchestra/testbench": "^8.0|^9.0", "pestphp/pest": "^2.34", "pestphp/pest-plugin-laravel": "^2.3", "phpstan/extension-installer": "^1.3", "phpstan/phpstan-deprecation-rules": "^1.1", "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^10.1" + "phpunit/phpunit": "^10.1", + "spatie/laravel-ray": "^1.37" }, "autoload": { "psr-4": { diff --git a/config/filament-shield.php b/config/filament-shield.php index f03d8d7d..451c6c52 100644 --- a/config/filament-shield.php +++ b/config/filament-shield.php @@ -13,6 +13,8 @@ 'cluster' => null, ], + 'tenant_model' => null, + 'auth_provider_model' => [ 'fqcn' => 'App\\Models\\User', ], diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e6dfaca5..6a340542 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -43,4 +43,5 @@ parameters: tmpDir: build/phpstan checkOctaneCompatibility: true - checkModelProperties: true \ No newline at end of file + checkModelProperties: true + treatPhpDocTypesAsCertain: false \ No newline at end of file diff --git a/resources/lang/en/filament-shield.php b/resources/lang/en/filament-shield.php index 9ac503b8..7016ef01 100644 --- a/resources/lang/en/filament-shield.php +++ b/resources/lang/en/filament-shield.php @@ -9,6 +9,7 @@ 'column.name' => 'Name', 'column.guard_name' => 'Guard Name', + 'column.team' => 'Team', 'column.roles' => 'Roles', 'column.permissions' => 'Permissions', 'column.updated_at' => 'Updated At', @@ -22,8 +23,10 @@ 'field.name' => 'Name', 'field.guard_name' => 'Guard Name', 'field.permissions' => 'Permissions', + 'field.team' => 'Team', + 'field.team.placeholder' => 'Select a team ...', 'field.select_all.name' => 'Select All', - 'field.select_all.message' => 'Enable all Permissions currently Enabled for this role', + 'field.select_all.message' => 'Enables/Disables all Permissions for this role', /* |-------------------------------------------------------------------------- diff --git a/src/Commands/Concerns/CanBeProhibitable.php b/src/Commands/Concerns/CanBeProhibitable.php new file mode 100644 index 00000000..55509522 --- /dev/null +++ b/src/Commands/Concerns/CanBeProhibitable.php @@ -0,0 +1,43 @@ +components->warn('This command is prohibited from running in this environment.'); + } + + return true; + } +} diff --git a/src/Commands/Concerns/CanGenerateRelationshipsForTenancy.php b/src/Commands/Concerns/CanGenerateRelationshipsForTenancy.php new file mode 100644 index 00000000..da990958 --- /dev/null +++ b/src/Commands/Concerns/CanGenerateRelationshipsForTenancy.php @@ -0,0 +1,167 @@ +getResources()) + ->values() + ->filter(function ($resource): bool { + return filled($this->guessResourceModelRelationshipType($resource::getModel(), Filament::getTenantModel())); + }) + ->map(function ($resource) { + $resource = resolve($resource); + $tenantModel = Filament::getTenantModel(); + + return [ + 'model' => $model = $resource::getModel(), + 'modelPath' => (new ReflectionClass(resolve($model)))->getFileName(), + 'tenantModelPath' => (new ReflectionClass(resolve($tenantModel)))->getFileName(), + 'resource_model_method' => [ + 'name' => $resource::getTenantOwnershipRelationshipName(), + 'relationshipName' => $this->guessResourceModelRelationshipType($model, $tenantModel), + 'relatedModelClass' => str($tenantModel) + ->prepend('\\') + ->append('::class') + ->toString(), + ], + 'tenant_model_method' => [ + 'name' => $resource::getTenantRelationshipName(), + 'relationshipName' => $this->guessTenantModelRelationshipType($model, $tenantModel), + 'relatedModelClass' => str($model) + ->prepend('\\') + ->append('::class') + ->toString(), + ], + ]; + }) + ->each(function ($modifiedResource) { + $resourceModelStringer = Stringer::for($modifiedResource['modelPath']); + $tenantModelstringer = Stringer::for($modifiedResource['tenantModelPath']); + + if (! $resourceModelStringer->contains($modifiedResource['resource_model_method']['name'])) { + if (filled($importStatement = $this->addModelReturnTypeImportStatement($modifiedResource['resource_model_method']['relationshipName']))) { + if (! $resourceModelStringer->contains($importStatement)) { + $resourceModelStringer->append('use', $importStatement); + } + } + $resourceModelStringer + ->newLine() + ->indent(4) + ->prependBeforeLast('}', $this->methodStubGenerator( + $modifiedResource['resource_model_method']['name'], + $modifiedResource['resource_model_method']['relationshipName'], + $modifiedResource['resource_model_method']['relatedModelClass'] + )) + ->save(); + } + if (! $tenantModelstringer->contains($modifiedResource['tenant_model_method']['name'])) { + if (filled($importStatement = $this->addModelReturnTypeImportStatement($modifiedResource['tenant_model_method']['relationshipName']))) { + if (! $tenantModelstringer->contains($importStatement)) { + $tenantModelstringer->append('use', $importStatement); + } + } + + $tenantModelstringer + ->newLine() + ->indent(4) + ->prependBeforeLast('}', $this->methodStubGenerator( + $modifiedResource['tenant_model_method']['name'], + $modifiedResource['tenant_model_method']['relationshipName'], + $modifiedResource['tenant_model_method']['relatedModelClass'] + )) + ->save(); + } + }) + ->toArray(); + + $this->components->info('Relationships have been generated successfully!'); + } + + protected function getModel(string $model): ?Model + { + if (! class_exists($model)) { + return null; + } + + return app($model); + } + + protected function getModelSchema(string $model): Builder + { + return $this->getModel($model) + ->getConnection() + ->getSchemaBuilder(); + } + + protected function getModelTable(string $model): string + { + return $this->getModel($model)->getTable(); + } + + protected function guessResourceModelRelationshipType(string $model, string $tenantModel): ?string + { + $schema = $this->getModelSchema($model); + $table = $this->getModelTable($model); + $columns = $schema->getColumnListing($table); + $foreignKeyPrefix = class_basename($this->getModel($tenantModel)); + + $foreignKey = str($foreignKeyPrefix)->snake()->append('_id'); + $morphType = str($foreignKeyPrefix)->snake()->append('_type'); + + return match (true) { + in_array($foreignKey, $columns) => 'belongsTo', + /** @phpstan-ignore-next-line */ + in_array($morphType, $columns) && in_array($foreignKey, $columns) => 'morphTo', + default => null, + }; + } + + protected function guessTenantModelRelationshipType(string $model, string $tenantModel): ?string + { + $resourceModelRelationshipType = $this->guessResourceModelRelationshipType($model, $tenantModel); + + return match ($resourceModelRelationshipType) { + 'belongsTo' => 'hasMany', + 'morphTo' => 'morphMany', + default => null, + }; + } + + protected function methodStubGenerator(string $name, string $relationshipName, string $related): string + { + $returnType = str($related)->beforeLast('::')->toString(); + $stubs = [ + 'belongsTo' => " /** @return BelongsTo<{$returnType}, self> */\n public function {$name}(): BelongsTo\n {\n return \$this->belongsTo({$related});\n }", + 'morphTo' => " /** @return MorphTo<{$returnType}, self> */\n public function {$name}(): MorphTo\n {\n return \$this->morphTo();\n }", + 'hasMany' => " /** @return HasMany<{$returnType}, self> */\n public function {$name}(): HasMany\n {\n return \$this->hasMany({$related});\n }", + 'morphMany' => " /** @return MorphMany<{$returnType}, self> */\n public function {$name}(): MorphMany\n {\n return \$this->morphMany({$related});\n }", + ]; + + return $stubs[$relationshipName] ?? "// No relationship defined for the given name: {$relationshipName}\n"; + } + + protected function addModelReturnTypeImportStatement(string $relationshipName): ?string + { + return match ($relationshipName) { + 'belongsTo' => 'use Illuminate\Database\Eloquent\Relations\BelongsTo;', + 'hasMany' => 'use Illuminate\Database\Eloquent\Relations\HasMany;', + 'morphTo' => 'use Illuminate\Database\Eloquent\Relations\MorphTo;', + 'morphMany' => 'use Illuminate\Database\Eloquent\Relations\MorphMany;', + default => null, + }; + } +} diff --git a/src/Commands/Concerns/CanMakePanelTenantable.php b/src/Commands/Concerns/CanMakePanelTenantable.php new file mode 100644 index 00000000..f250b64d --- /dev/null +++ b/src/Commands/Concerns/CanMakePanelTenantable.php @@ -0,0 +1,61 @@ +hasTenancy()) { + + Stringer::for($panelPath) + ->prepend('->discoverResources', '->tenant(' . $tenantModelClass . ')') + ->save(); + $this->activateTenancy($panelPath); + + $this->components->info("Panel `{$panel->getId()}` is now tenantable."); + } + + if ($panel->hasTenancy()) { + $this->activateTenancy($panelPath); + + $this->components->info("Panel `{$panel->getId()}` is now tenantable."); + } + } + + private function activateTenancy(string $panelPath): void + { + $stringer = Stringer::for($panelPath); + + $target = $stringer->contains('->plugins([') ? '->plugins([' : '->middleware(['; + $shieldMiddlewareImportStatement = 'use BezhanSalleh\FilamentShield\Middleware\SyncShieldTenant;'; + $shieldMiddleware = 'SyncShieldTenant::class,'; + $tenantMiddlewareMarker = '->tenantMiddleware(['; + + if (! $stringer->contains($shieldMiddlewareImportStatement)) { + $stringer->append('use', $shieldMiddlewareImportStatement); + } + + $stringer->when( + value: (! $stringer->contains($shieldMiddleware) && $stringer->contains($tenantMiddlewareMarker)), + callback: fn (Stringer $stringer): bool => $stringer + ->indent(4) + ->append('->tenantMiddleware([', $shieldMiddleware) + ->save() + ); + $stringer->when( + value: (! $stringer->contains($shieldMiddleware) && ! $stringer->contains($tenantMiddlewareMarker)), + callback: fn (Stringer $stringer): bool => $stringer + ->append($target, $tenantMiddlewareMarker, true) + ->append($tenantMiddlewareMarker, '], isPersistent: true)') + ->indent(4) + ->prepend('], isPersistent: true)', $shieldMiddleware) + ->save() + ); + } +} diff --git a/src/Commands/Concerns/CanManipulateFiles.php b/src/Commands/Concerns/CanManipulateFiles.php index ef01b54b..2392ff02 100644 --- a/src/Commands/Concerns/CanManipulateFiles.php +++ b/src/Commands/Concerns/CanManipulateFiles.php @@ -24,7 +24,7 @@ protected function copyStubToApp(string $stub, string $targetPath, array $replac { $filesystem = new Filesystem; - if (! $this->fileExists($stubPath = base_path("stubs/filament/{$stub}.stub"))) { + if (! $this->fileExists($stubPath = base_path("stubs/filament-shield/{$stub}.stub"))) { $stubPath = __DIR__ . "/../../../stubs/{$stub}.stub"; } @@ -65,4 +65,20 @@ protected function replaceInFile(string $file, string $search, string $replace): str_replace($search, $replace, file_get_contents($file)) ); } + + protected function copy(string $source, string $destination): bool + { + $filesystem = new Filesystem; + + if (! $this->fileExists($destination)) { + $filesystem->copy($source, $destination); + $this->components->info("$destination file published!"); + + return true; + } + + $this->components->warn("$destination already exists, skipping ..."); + + return false; + } } diff --git a/src/Commands/Concerns/CanRegisterPlugin.php b/src/Commands/Concerns/CanRegisterPlugin.php new file mode 100644 index 00000000..8c5cffcb --- /dev/null +++ b/src/Commands/Concerns/CanRegisterPlugin.php @@ -0,0 +1,69 @@ +plugins([\n"; + $pluginsTarget = '->middleware(['; + + if ($stringer->contains($shieldPlugin)) { + $this->components->warn('Shield plugin is already registered! skipping...'); + } else { + + $stringer + ->when( + value: ! $stringer->contains($shieldPluginImportStatement), + callback: fn (Stringer $stringer): Stringer => $stringer + ->append('use', $shieldPluginImportStatement) + ) + ->when( /** @phpstan-ignore-next-line */ + value: $stringer->contains($pluginsArray) && (! $stringer->contains($shieldPlugin)), + callback: fn (Stringer $stringer): Stringer => $stringer + ->when( + value: $centralApp, + callback: fn (Stringer $stringer) => $stringer + ->indent(4) + ->append($pluginsArray, $shieldPlugin) + ->append($shieldPlugin, '->centralApp(' . $tenantModelClass . '),'), + default: fn (Stringer $stringer) => $stringer + ->indent(4) + ->append($pluginsArray, $shieldPlugin . ',') + ), + ) + ->when(/** @phpstan-ignore-next-line */ + value: (! $stringer->contains($shieldPlugin) && ! $stringer->contains($pluginsArray)), + callback: fn (Stringer $stringer): Stringer => $stringer + ->when( + value: $centralApp, + callback: fn (Stringer $stringer) => $stringer + ->append($pluginsTarget, $pluginsArray, true) + ->append($pluginsArray, '])') + ->indent(4) + ->append($pluginsArray, $shieldPlugin) + ->append($shieldPlugin, '->centralApp(' . $tenantModelClass . '),'), + default: fn (Stringer $stringer) => $stringer + ->append($pluginsTarget, $pluginsArray, true) + ->append($pluginsArray, '])') + ->indent(4) + ->append($pluginsArray, $shieldPlugin . ',') + ) + ) + ->save(); + + $this->components->info('Shield plugin has been registered successfully!'); + + } + + } +} diff --git a/src/Commands/MakeShieldGenerateCommand.php b/src/Commands/GenerateCommand.php similarity index 93% rename from src/Commands/MakeShieldGenerateCommand.php rename to src/Commands/GenerateCommand.php index 1893b714..ce0096d9 100644 --- a/src/Commands/MakeShieldGenerateCommand.php +++ b/src/Commands/GenerateCommand.php @@ -4,15 +4,19 @@ use BezhanSalleh\FilamentShield\Facades\FilamentShield; use BezhanSalleh\FilamentShield\Support\Utils; +use Filament\Facades\Filament; use Illuminate\Console\Command; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; +use function Laravel\Prompts\Select; + #[AsCommand(name: 'shield:generate')] -class MakeShieldGenerateCommand extends Command +class GenerateCommand extends Command { + use Concerns\CanBeProhibitable; use Concerns\CanGeneratePolicy; use Concerns\CanManipulateFiles; @@ -45,14 +49,10 @@ class MakeShieldGenerateCommand extends Command protected bool $onlyWidgets = false; - /** - * The console command signature. - * - * @var string - */ + /** @var string */ public $signature = 'shield:generate {--all : Generate permissions/policies for all entities } - {--option= : Override the config generator option(policies_and_permissions,policies,permissions)} + {--option= : Override the config generator option(policies_and_permissions,policies,permissions and tenant_relationships)} {--resource= : One or many resources separated by comma (,) } {--page= : One or many pages separated by comma (,) } {--widget= : One or many widgets separated by comma (,) } @@ -60,19 +60,23 @@ class MakeShieldGenerateCommand extends Command {--ignore-config-exclude : Ignore config `exclude` option during generation } {--minimal : Output minimal amount of info to console} {--ignore-existing-policies : Ignore generating policies that already exist } + {--panel= : Panel ID to get the components(resources, pages, widgets)} '; - // {--seeder : Exclude the given entities during generation } - // the idea is to generate a seeder that can be used on production deployment - /** - * The console command description. - * - * @var string - */ + /** @var string */ public $description = 'Generate Permissions and/or Policies for Filament entities.'; public function handle(): int { + $panel = $this->option('panel') + ? $this->option('panel') + : Select( + label: 'Which panel do you want to generate permissions/policies for?', + options: collect(Filament::getPanels())->keys()->toArray() + ); + + Filament::setCurrentPanel(Filament::getPanel($panel)); + $this->determinGeneratorOptionAndEntities(); if ($this->option('exclude') && blank($this->option('resource')) && blank($this->option('page')) && blank($this->option('widget'))) { @@ -97,9 +101,6 @@ public function handle(): int $this->widgetInfo($widgets->toArray()); } - $this->components->info('Permission & Policies are generated according to your config or passed options.'); - $this->components->info('Enjoy!'); - if (Cache::has('shield_general_exclude')) { Utils::enableGeneralExclude(); Cache::forget('shield_general_exclude'); diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php new file mode 100644 index 00000000..b19213f8 --- /dev/null +++ b/src/Commands/InstallCommand.php @@ -0,0 +1,94 @@ +argument('panel') ?? null); + + $tenant = $this->option('tenant') ? config()->get('filament-shield.tenant_model') : null; + + $tenantModelClass = str($tenant) + ->prepend('\\') + ->append('::class') + ->toString(); + + $panelPath = app_path( + (string) str($panel->getId()) + ->studly() + ->append('PanelProvider') + ->prepend('Providers/Filament/') + ->replace('\\', '/') + ->append('.php'), + ); + + if (! $this->fileExists($panelPath)) { + $this->error("Panel not found: {$panelPath}"); + + return static::FAILURE; + } + + // if ($panel->hasTenancy() && $shouldSetPanelAsCentralApp) { + // $this->components->warn('Cannot install Shield as `Central App` on a tenant panel!'); + // return static::FAILURE; + // } + + // if (! $panel->hasTenancy() && $shouldSetPanelAsCentralApp && blank($tenant)) { + // $this->components->warn('Make sure you have at least a panel with tenancy setup first!'); + // return static::INVALID; + // } + + $this->registerPlugin( + panelPath: $panelPath, + /** @phpstan-ignore-next-line */ + centralApp: $shouldSetPanelAsCentralApp && ! $panel->hasTenancy(), + tenantModelClass: $tenantModelClass + ); + + /** @phpstan-ignore-next-line */ + if (filled($tenant) && ! $shouldSetPanelAsCentralApp) { + $this->makePanelTenantable( + panel: $panel, + panelPath: $panelPath, + tenantModelClass: $tenantModelClass + ); + } + + if (filled($tenant) && $this->option('generate-relationships')) { + $this->generateRelationships($panel); + } + + $this->call('shield:generate', [ + '--resource' => 'RoleResource', + '--panel' => $panel->getId(), + ]); + + $this->components->info('Shield has been successfully configured & installed!'); + + return static::SUCCESS; + } +} diff --git a/src/Commands/MakeShieldDoctorCommand.php b/src/Commands/MakeShieldDoctorCommand.php deleted file mode 100644 index d7850022..00000000 --- a/src/Commands/MakeShieldDoctorCommand.php +++ /dev/null @@ -1,47 +0,0 @@ - Utils::getAuthProviderFQCN() . '|' . static::authProviderConfigured(), - 'Resource' => Utils::isResourcePublished() ? 'PUBLISHED' : 'NOT PUBLISHED', - 'Resource Slug' => Utils::getResourceSlug(), - 'Resource Sort' => Utils::getResourceNavigationSort(), - 'Resource Badge' => Utils::isResourceNavigationBadgeEnabled() ? 'ENABLED' : 'DISABLED', - 'Resource Group' => Utils::isResourceNavigationGroupEnabled() ? 'ENABLED' : 'DISABLED', - 'Translations' => is_dir(resource_path('resource/lang/vendor/filament-shield')) ? 'PUBLISHED' : 'NOT PUBLISHED', - 'Views' => is_dir(resource_path('views/vendor/filament-shield')) ? 'PUBLISHED' : 'NOT PUBLISHED', - 'Version' => InstalledVersions::getPrettyVersion('bezhansalleh/filament-shield'), - ]); - - $this->call('about', [ - '--only' => 'filament_shield', - ]); - - return self::SUCCESS; - } - - protected static function authProviderConfigured(): string - { - if (class_exists(Utils::getAuthProviderFQCN())) { - return Utils::isAuthProviderConfigured() - ? 'CONFIGURED' - : 'NOT CONFIGURED'; - } - - return ''; - } -} diff --git a/src/Commands/MakeShieldPublishCommand.php b/src/Commands/MakeShieldPublishCommand.php deleted file mode 100644 index 44b924f9..00000000 --- a/src/Commands/MakeShieldPublishCommand.php +++ /dev/null @@ -1,47 +0,0 @@ -replace('\\', '/')); - $roleResourcePath = app_path((string) Str::of('Filament\\Resources\\Shield\\RoleResource.php')->replace('\\', '/')); - - if ($this->checkForCollision([$roleResourcePath])) { - $confirmed = confirm('Shield Resource already exists. Overwrite?'); - if (! $confirmed) { - return self::INVALID; - } - } - - $filesystem->ensureDirectoryExists($baseResourcePath); - $filesystem->copyDirectory(__DIR__ . '/../Resources', $baseResourcePath); - - $currentNamespace = 'BezhanSalleh\\FilamentShield\\Resources'; - $newNamespace = 'App\\Filament\\Resources\\Shield'; - - $this->replaceInFile($roleResourcePath, $currentNamespace, $newNamespace); - $this->replaceInFile($baseResourcePath . '/RoleResource/Pages/CreateRole.php', $currentNamespace, $newNamespace); - $this->replaceInFile($baseResourcePath . '/RoleResource/Pages/EditRole.php', $currentNamespace, $newNamespace); - $this->replaceInFile($baseResourcePath . '/RoleResource/Pages/ViewRole.php', $currentNamespace, $newNamespace); - $this->replaceInFile($baseResourcePath . '/RoleResource/Pages/ListRoles.php', $currentNamespace, $newNamespace); - - $this->components->info("Shield's Resource have been published successfully!"); - - return self::SUCCESS; - } -} diff --git a/src/Commands/MakeShieldUpgradeCommand.php b/src/Commands/MakeShieldUpgradeCommand.php deleted file mode 100644 index 206813a3..00000000 --- a/src/Commands/MakeShieldUpgradeCommand.php +++ /dev/null @@ -1,47 +0,0 @@ -where('migration', 'like', '%_filament_shield_settings_%')->delete(); - - DB::statement('DROP TABLE IF EXISTS filament_shield_settings'); - - Schema::enableForeignKeyConstraints(); - } catch (Throwable $e) { - $this->components->info($e); - - return self::FAILURE; - } - - $this->components->info('Filament Shield upgraded.'); - - return self::SUCCESS; - } -} diff --git a/src/Commands/PublishCommand.php b/src/Commands/PublishCommand.php new file mode 100644 index 00000000..22edb2d6 --- /dev/null +++ b/src/Commands/PublishCommand.php @@ -0,0 +1,68 @@ +argument('panel'))); + + $panel = Filament::getCurrentPanel(); + + $resourceDirectories = $panel->getResourceDirectories(); + $resourceNamespaces = $panel->getResourceNamespaces(); + + $newResourceNamespace = (count($resourceNamespaces) > 1) + ? select( + label: 'Which namespace would you like to publish this in?', + options: $resourceNamespaces + ) + : Arr::first($resourceNamespaces); + + $newResourcePath = (count($resourceDirectories) > 1) + ? $resourceDirectories[array_search($newResourceNamespace, $resourceNamespaces)] + : Arr::first($resourceDirectories); + + $roleResourcePath = str('\\RoleResource.php') + ->prepend($newResourcePath) + ->replace('\\', '/') + ->toString(); + + if ($this->checkForCollision([$roleResourcePath])) { + $confirmed = confirm('Shield Resource already exists. Overwrite?'); + if (! $confirmed) { + return self::INVALID; + } + } + + $filesystem->ensureDirectoryExists($newResourcePath); + $filesystem->copyDirectory(__DIR__ . '/../Resources', $newResourcePath); + + $currentNamespace = 'BezhanSalleh\\FilamentShield\\Resources'; + + $this->replaceInFile($roleResourcePath, $currentNamespace, $newResourceNamespace); + $this->replaceInFile($newResourcePath . '/RoleResource/Pages/CreateRole.php', $currentNamespace, $newResourceNamespace); + $this->replaceInFile($newResourcePath . '/RoleResource/Pages/EditRole.php', $currentNamespace, $newResourceNamespace); + $this->replaceInFile($newResourcePath . '/RoleResource/Pages/ViewRole.php', $currentNamespace, $newResourceNamespace); + $this->replaceInFile($newResourcePath . '/RoleResource/Pages/ListRoles.php', $currentNamespace, $newResourceNamespace); + + $this->components->info("Shield's Resource have been published successfully!"); + + return self::SUCCESS; + } +} diff --git a/src/Commands/MakeShieldInstallCommand.php b/src/Commands/SetupCommand.php similarity index 51% rename from src/Commands/MakeShieldInstallCommand.php rename to src/Commands/SetupCommand.php index 8fa93abb..233317f3 100644 --- a/src/Commands/MakeShieldInstallCommand.php +++ b/src/Commands/SetupCommand.php @@ -2,25 +2,32 @@ namespace BezhanSalleh\FilamentShield\Commands; +use BezhanSalleh\FilamentShield\Stringer; use BezhanSalleh\FilamentShield\Support\Utils; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Symfony\Component\Console\Attribute\AsCommand; use Throwable; use function Laravel\Prompts\confirm; -class MakeShieldInstallCommand extends Command +#[AsCommand(name: 'shield:setup', description: 'Setup and install core requirements for Shield')] +class SetupCommand extends Command { - public $signature = 'shield:install + use Concerns\CanBeProhibitable; + use Concerns\CanManipulateFiles; + + public $signature = 'shield:setup {--F|fresh : re-run the migrations} - {--O|only : Only setups shield without generating permissions and creating super-admin} {--minimal : Output minimal amount of info to console} + {--tenant= : Tenant model} + {--force} '; - public $description = 'Setup Core Package requirements and Install Shield'; + public $description = 'Setup and install core requirements for Shield'; public function handle(): int { @@ -94,14 +101,85 @@ protected function getTables(): Collection protected function install(bool $fresh = false): void { + if (filled($tenant = $this->option('tenant'))) { + $tenantModel = $this->getModel($tenant); + if (! $this->fileExists(config_path('filament-shield.php'))) { + $this->{$this->option('minimal') ? 'callSilent' : 'call'}('vendor:publish', [ + '--tag' => 'filament-shield-config', + '--force' => $this->option('force'), + ]); + } + + $shieldConfig = Stringer::for(config_path('filament-shield.php')); + + if (is_null(config()->get('filament-shield.tenant_model', null))) { + $shieldConfig->prepend('auth_provider_model', "'tenant_model' => null,") + ->newLine(); + } + + $shieldConfig + ->append('tenant_model', "'tenant_model' => '" . get_class($tenantModel) . "',") + ->deleteLine('tenant_model') + ->save(); + + if (! $this->fileExists(config_path('permission.php')) || $fresh) { + $this->call('vendor:publish', [ + '--tag' => 'permission-config', + '--force' => true, + ]); + } + + Stringer::for(config_path('permission.php')) + ->replace("'teams' => false,", "'teams' => true,") + ->save(); + + config()->set('permission.teams', true); + + $source = __DIR__ . '/../Support/'; + $destination = app_path('Models'); + + $this->copy($source . '/Role.php', $destination . '/Role.php'); + $this->copy($source . '/Permission.php', $destination . '/Permission.php'); + + $appServiceProvider = Stringer::for(app_path('Providers/AppServiceProvider.php')); + if ( + ! $appServiceProvider->containsChainedBlock('app(\Spatie\Permission\PermissionRegistrar::class) + ->setPermissionClass(Permission::class) + ->setRoleClass(Role::class)') + ) { + if (! $appServiceProvider->contains('use App\Models\Role;')) { + $appServiceProvider->append('use', 'use App\Models\Role;'); + } + + if (! $appServiceProvider->contains('use App\Models\Permission;')) { + $appServiceProvider->append('use', 'use App\Models\Permission;'); + } + + $appServiceProvider + ->appendBlock('public function boot()', " + app(\Spatie\Permission\PermissionRegistrar::class) + ->setPermissionClass(Permission::class) + ->setRoleClass(Role::class); + ", true) + ->save(); + } + } else { + $this->call('vendor:publish', [ + '--tag' => 'permission-config', + '--force' => $this->option('force') || $fresh, + ]); + } + $this->{$this->option('minimal') ? 'callSilent' : 'call'}('vendor:publish', [ - '--provider' => 'Spatie\Permission\PermissionServiceProvider', + '--tag' => 'permission-migrations', + '--force' => $this->option('force') || $fresh, ]); $this->components->info('Core Package config published.'); $this->{$this->option('minimal') ? 'callSilent' : 'call'}('vendor:publish', [ '--tag' => 'filament-shield-config', + '--force' => $this->option('force') || $fresh, ]); if ($fresh) { @@ -120,27 +198,25 @@ protected function install(bool $fresh = false): void } $this->{$this->option('minimal') ? 'callSilent' : 'call'}('migrate', [ - '--force' => true, + '--force' => $fresh, ]); - if (! $this->option('only')) { - $this->components->info('Generating permissions ...'); - $this->call('shield:generate', [ - '--all' => true, - '--minimal' => $this->option('minimal'), - ]); + $this->components->info('Filament Shield๐Ÿ›ก is now active โœ…'); + } - $this->components->info('Creating a filament user with Super Admin Role...'); - $this->call('shield:super-admin'); - } else { - $this->call('shield:generate', [ - '--resource' => 'RoleResource', - '--minimal' => $this->option('minimal'), - ]); + protected function getModel(string $model): ?Model + { + $model = str($model)->contains('\\') + ? $model + : str($model)->prepend('App\\Models\\') + ->toString(); + + if (! class_exists($model) || ! (app($model) instanceof Model)) { + $this->components->error("Model not found: {$model}"); + /** @phpstan-ignore-next-line */ + exit(); } - $this->components->info(Artisan::output()); - - $this->components->info('Filament Shield๐Ÿ›ก is now active โœ…'); + return app($model); } } diff --git a/src/Commands/MakeShieldSuperAdminCommand.php b/src/Commands/SuperAdminCommand.php similarity index 79% rename from src/Commands/MakeShieldSuperAdminCommand.php rename to src/Commands/SuperAdminCommand.php index 8b00801b..db3a333d 100644 --- a/src/Commands/MakeShieldSuperAdminCommand.php +++ b/src/Commands/SuperAdminCommand.php @@ -15,17 +15,21 @@ use function Laravel\Prompts\password; use function Laravel\Prompts\text; -class MakeShieldSuperAdminCommand extends Command +class SuperAdminCommand extends Command { public $signature = 'shield:super-admin {--user= : ID of user to be made super admin.} {--panel= : Panel ID to get the configuration from.} + {--tenant= : Team/Tenant ID to assign role to user.} '; public $description = 'Creates Filament Super Admin'; protected Authenticatable $superAdmin; + /** @var ?\Illuminate\Database\Eloquent\Model */ + protected $superAdminRole = null; + protected function getAuthGuard(): Guard { if ($this->option('panel')) { @@ -51,10 +55,7 @@ protected function getUserModel(): string public function handle(): int { $usersCount = static::getUserModel()::count(); - - if (Utils::getRoleModel()::whereName(Utils::getSuperAdminName())->doesntExist()) { - FilamentShield::createRole(); - } + $tenantId = $this->option('tenant'); if ($this->option('user')) { $this->superAdmin = static::getUserModel()::findOrFail($this->option('user')); @@ -84,7 +85,26 @@ public function handle(): int $this->superAdmin = $this->createSuperAdmin(); } - $this->superAdmin->assignRole(Utils::getSuperAdminName()); + if (Utils::isTeamFeatureEnabled()) { + if (blank($tenantId)) { + $this->components->error('Please provide the team/tenant id via `--tenant` option to assign the super admin to a team/tenant.'); + + return self::FAILURE; + } + setPermissionsTeamId($tenantId); + $this->superAdminRole = FilamentShield::createRole(team_id: $tenantId); + $this->superAdminRole->syncPermissions(Utils::getPermissionModel()::pluck('id')); + + } else { + $this->superAdminRole = FilamentShield::createRole(); + } + + $this->superAdmin + ->unsetRelation('roles') + ->unsetRelation('permissions'); + + $this->superAdmin + ->assignRole($this->superAdminRole); $loginUrl = Filament::getCurrentPanel()?->getLoginUrl(); diff --git a/src/Concerns/CanBeCentralApp.php b/src/Concerns/CanBeCentralApp.php new file mode 100644 index 00000000..b9fadc42 --- /dev/null +++ b/src/Concerns/CanBeCentralApp.php @@ -0,0 +1,33 @@ +tenantModel = $model; + + $this->isCentralApp = $condition; + + return $this; + } + + public function isCentralApp(): bool + { + return (bool) $this->evaluate($this->isCentralApp); + } + + public function getTenantModel(): ?string + { + return $this->evaluate($this->tenantModel); + } +} diff --git a/src/Concerns/HasAboutCommand.php b/src/Concerns/HasAboutCommand.php new file mode 100644 index 00000000..6263c738 --- /dev/null +++ b/src/Concerns/HasAboutCommand.php @@ -0,0 +1,41 @@ + Utils::getAuthProviderFQCN() . '|' . static::authProviderConfigured(), + // 'Resource' => Utils::isResourcePublished(Filament::getCurrentPanel()) ? 'PUBLISHED' : 'NOT PUBLISHED', + // 'Resource Slug' => Utils::getResourceSlug(), + // 'Resource Sort' => Utils::getResourceNavigationSort(), + // 'Resource Badge' => Utils::isResourceNavigationBadgeEnabled() ? 'ENABLED' : 'DISABLED', + // 'Resource Group' => Utils::isResourceNavigationGroupEnabled() ? 'ENABLED' : 'DISABLED', + 'Tenancy' => Utils::isTeamFeatureEnabled() ? 'ENABLED' : 'DISABLED', + 'Tenant Model' => Utils::isTeamFeatureEnabled() && filled($model = config()->get('filament-shield.tenant_model')) ? $model : null, + 'Translations' => is_dir(resource_path('resource/lang/vendor/filament-shield')) ? 'PUBLISHED' : 'NOT PUBLISHED', + 'Views' => is_dir(resource_path('views/vendor/filament-shield')) ? 'PUBLISHED' : 'NOT PUBLISHED', + 'Version' => InstalledVersions::getPrettyVersion('bezhansalleh/filament-shield'), + ]); + } + + protected static function authProviderConfigured(): string + { + if (class_exists(Utils::getAuthProviderFQCN())) { + return Utils::isAuthProviderConfigured() + ? 'CONFIGURED' + : 'NOT CONFIGURED'; + } + + return ''; + } +} diff --git a/src/FilamentShield.php b/src/FilamentShield.php index dce88297..9bccfa62 100755 --- a/src/FilamentShield.php +++ b/src/FilamentShield.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Lang; use Illuminate\Support\Str; +use Spatie\Permission\Models\Role; use Spatie\Permission\PermissionRegistrar; class FilamentShield @@ -105,8 +106,18 @@ protected static function giveSuperAdminPermission(string | array | Collection $ } } - public static function createRole(?string $name = null) + public static function createRole(?string $name = null, ?int $team_id = null): Role { + if (Utils::isTeamFeatureEnabled()) { + return Utils::getRoleModel()::firstOrCreate( + [ + 'name' => $name ?? Utils::getSuperAdminName(), + 'team_id' => $team_id, + ], + ['guard_name' => Utils::getFilamentAuthGuard()] + ); + } + return Utils::getRoleModel()::firstOrCreate( ['name' => $name ?? Utils::getSuperAdminName()], ['guard_name' => Utils::getFilamentAuthGuard()] @@ -384,4 +395,18 @@ protected function getEntitiesPermissions(): ?array ->unique() ->toArray(); } + + /** + * Indicate if destructive Shield commands should be prohibited. + * + * Prohibits: shield:setup, shield:install, and shield:generate + * + * @return void + */ + public static function prohibitDestructiveCommands(bool $prohibit = true) + { + Commands\SetupCommand::prohibit($prohibit); + Commands\InstallCommand::prohibit($prohibit); + Commands\GenerateCommand::prohibit($prohibit); + } } diff --git a/src/FilamentShieldPlugin.php b/src/FilamentShieldPlugin.php index 3d5b96f7..54ee55d8 100644 --- a/src/FilamentShieldPlugin.php +++ b/src/FilamentShieldPlugin.php @@ -7,12 +7,15 @@ use BezhanSalleh\FilamentShield\Support\Utils; use Filament\Contracts\Plugin; use Filament\Panel; +use Filament\Support\Concerns\EvaluatesClosures; class FilamentShieldPlugin implements Plugin { + use Concerns\CanBeCentralApp; use Concerns\CanCustomizeColumns; use Concerns\CanLocalizePermissionLabels; use Concerns\HasSimpleResourcePermissionView; + use EvaluatesClosures; public static function make(): static { @@ -26,7 +29,8 @@ public function getId(): string public function register(Panel $panel): void { - if (! Utils::isResourcePublished()) { + + if (! Utils::isResourcePublished($panel)) { $panel->resources([ Resources\RoleResource::class, ]); diff --git a/src/FilamentShieldServiceProvider.php b/src/FilamentShieldServiceProvider.php index 6d31e1f2..566bbf28 100644 --- a/src/FilamentShieldServiceProvider.php +++ b/src/FilamentShieldServiceProvider.php @@ -9,6 +9,8 @@ class FilamentShieldServiceProvider extends PackageServiceProvider { + use Concerns\HasAboutCommand; + public function configurePackage(Package $package): void { $package @@ -32,6 +34,8 @@ public function packageBooted(): void { parent::packageBooted(); + $this->initAboutCommand(); + if (Utils::isSuperAdminDefinedViaGate()) { Gate::{Utils::getSuperAdminGateInterceptionStatus()}(function ($user, $ability) { return match (Utils::getSuperAdminGateInterceptionStatus()) { @@ -50,13 +54,12 @@ public function packageBooted(): void protected function getCommands(): array { return [ - Commands\MakeShieldDoctorCommand::class, + Commands\GenerateCommand::class, + Commands\InstallCommand::class, + Commands\PublishCommand::class, + Commands\SetupCommand::class, + Commands\SuperAdminCommand::class, Commands\MakeShieldSeederCommand::class, - Commands\MakeShieldPublishCommand::class, - Commands\MakeShieldUpgradeCommand::class, - Commands\MakeShieldInstallCommand::class, - Commands\MakeShieldGenerateCommand::class, - Commands\MakeShieldSuperAdminCommand::class, ]; } } diff --git a/src/Middleware/SyncShieldTenant.php b/src/Middleware/SyncShieldTenant.php new file mode 100644 index 00000000..573c085b --- /dev/null +++ b/src/Middleware/SyncShieldTenant.php @@ -0,0 +1,26 @@ +nullable() ->maxLength(255), + Forms\Components\Select::make('team_id') + ->label(__('filament-shield::filament-shield.field.team')) + ->placeholder(__('filament-shield::filament-shield.field.team.placeholder')) + /** @phpstan-ignore-next-line */ + ->default([Filament::getTenant()?->id]) + ->options(fn (): Arrayable => static::shield()->getTenantModel()::pluck('name', 'id')) + ->hidden(fn (): bool => ! static::shield()->isCentralApp()) + ->dehydratedWhenHidden(), + ShieldSelectAllToggle::make('select_all') ->onIcon('heroicon-s-shield-check') ->offIcon('heroicon-s-shield-exclamation') ->label(__('filament-shield::filament-shield.field.select_all.name')) ->helperText(fn (): HtmlString => new HtmlString(__('filament-shield::filament-shield.field.select_all.message'))) - ->dehydrated(fn ($state): bool => $state), + ->dehydrated(fn (bool $state): bool => $state), ]) ->columns([ @@ -67,15 +77,7 @@ public static function form(Form $form): Form 'lg' => 3, ]), ]), - Forms\Components\Tabs::make('Permissions') - ->contained() - ->tabs([ - static::getTabFormComponentForResources(), - static::getTabFormComponentForPage(), - static::getTabFormComponentForWidget(), - static::getTabFormComponentForCustomPermissions(), - ]) - ->columnSpan('full'), + static::getShieldFormComponents(), ]); } @@ -84,14 +86,21 @@ public static function table(Table $table): Table return $table ->columns([ Tables\Columns\TextColumn::make('name') - ->badge() + ->weight('font-medium') ->label(__('filament-shield::filament-shield.column.name')) ->formatStateUsing(fn ($state): string => Str::headline($state)) - ->colors(['primary']) ->searchable(), Tables\Columns\TextColumn::make('guard_name') ->badge() + ->color('warning') ->label(__('filament-shield::filament-shield.column.guard_name')), + Tables\Columns\TextColumn::make('team.name') + ->default('Global') + ->badge() + ->color(fn (mixed $state): string => str($state)->contains('Global') ? 'gray' : 'primary') + ->label(__('filament-shield::filament-shield.column.team')) + ->searchable() + ->visible(fn (): bool => static::shield()->isCentralApp() && ! Filament::hasTenancy()), Tables\Columns\TextColumn::make('permissions_count') ->badge() ->label(__('filament-shield::filament-shield.column.permissions')) @@ -198,204 +207,4 @@ public static function canGloballySearch(): bool { return Utils::isResourceGloballySearchable() && count(static::getGloballySearchableAttributes()) && static::canViewAny(); } - - public static function getResourceEntitiesSchema(): ?array - { - return collect(FilamentShield::getResources()) - ->sortKeys() - ->map(function ($entity) { - $sectionLabel = strval( - static::shield()->hasLocalizedPermissionLabels() - ? FilamentShield::getLocalizedResourceLabel($entity['fqcn']) - : $entity['model'] - ); - - return Forms\Components\Section::make($sectionLabel) - ->description(fn () => new HtmlString('' . Utils::showModelPath($entity['fqcn']) . '')) - ->compact() - ->schema([ - static::getCheckBoxListComponentForResource($entity), - ]) - ->columnSpan(static::shield()->getSectionColumnSpan()) - ->collapsible(); - }) - ->toArray(); - } - - public static function getResourceTabBadgeCount(): ?int - { - return collect(FilamentShield::getResources()) - ->map(fn ($resource) => count(static::getResourcePermissionOptions($resource))) - ->sum(); - } - - public static function getResourcePermissionOptions(array $entity): array - { - return collect(Utils::getResourcePermissionPrefixes($entity['fqcn'])) - ->flatMap(function ($permission) use ($entity) { - $name = $permission . '_' . $entity['resource']; - $label = static::shield()->hasLocalizedPermissionLabels() - ? FilamentShield::getLocalizedResourcePermissionLabel($permission) - : $name; - - return [ - $name => $label, - ]; - }) - ->toArray(); - } - - public static function setPermissionStateForRecordPermissions(Component $component, string $operation, array $permissions, ?Model $record): void - { - if (in_array($operation, ['edit', 'view'])) { - - if (blank($record)) { - return; - } - if ($component->isVisible() && count($permissions) > 0) { - $component->state( - collect($permissions) - /** @phpstan-ignore-next-line */ - ->filter(fn ($value, $key) => $record->checkPermissionTo($key)) - ->keys() - ->toArray() - ); - } - } - } - - public static function getPageOptions(): array - { - return collect(FilamentShield::getPages()) - ->flatMap(fn ($page) => [ - $page['permission'] => static::shield()->hasLocalizedPermissionLabels() - ? FilamentShield::getLocalizedPageLabel($page['class']) - : $page['permission'], - ]) - ->toArray(); - } - - public static function getWidgetOptions(): array - { - return collect(FilamentShield::getWidgets()) - ->flatMap(fn ($widget) => [ - $widget['permission'] => static::shield()->hasLocalizedPermissionLabels() - ? FilamentShield::getLocalizedWidgetLabel($widget['class']) - : $widget['permission'], - ]) - ->toArray(); - } - - public static function getCustomPermissionOptions(): ?array - { - return FilamentShield::getCustomPermissions() - ->mapWithKeys(fn ($customPermission) => [ - $customPermission => static::shield()->hasLocalizedPermissionLabels() ? str($customPermission)->headline()->toString() : $customPermission, - ]) - ->toArray(); - } - - public static function getTabFormComponentForResources(): Component - { - return static::shield()->hasSimpleResourcePermissionView() - ? static::getTabFormComponentForSimpleResourcePermissionsView() - : Forms\Components\Tabs\Tab::make('resources') - ->label(__('filament-shield::filament-shield.resources')) - ->visible(fn (): bool => (bool) Utils::isResourceEntityEnabled()) - ->badge(static::getResourceTabBadgeCount()) - ->schema([ - Forms\Components\Grid::make() - ->schema(static::getResourceEntitiesSchema()) - ->columns(static::shield()->getGridColumns()), - ]); - } - - public static function getCheckBoxListComponentForResource(array $entity): Component - { - $permissionsArray = static::getResourcePermissionOptions($entity); - - return static::getCheckboxListFormComponent($entity['resource'], $permissionsArray, false); - } - - public static function getTabFormComponentForPage(): Component - { - $options = static::getPageOptions(); - $count = count($options); - - return Forms\Components\Tabs\Tab::make('pages') - ->label(__('filament-shield::filament-shield.pages')) - ->visible(fn (): bool => (bool) Utils::isPageEntityEnabled() && $count > 0) - ->badge($count) - ->schema([ - static::getCheckboxListFormComponent('pages_tab', $options), - ]); - } - - public static function getTabFormComponentForWidget(): Component - { - $options = static::getWidgetOptions(); - $count = count($options); - - return Forms\Components\Tabs\Tab::make('widgets') - ->label(__('filament-shield::filament-shield.widgets')) - ->visible(fn (): bool => (bool) Utils::isWidgetEntityEnabled() && $count > 0) - ->badge($count) - ->schema([ - static::getCheckboxListFormComponent('widgets_tab', $options), - ]); - } - - public static function getTabFormComponentForCustomPermissions(): Component - { - $options = static::getCustomPermissionOptions(); - $count = count($options); - - return Forms\Components\Tabs\Tab::make('custom') - ->label(__('filament-shield::filament-shield.custom')) - ->visible(fn (): bool => (bool) Utils::isCustomPermissionEntityEnabled() && $count > 0) - ->badge($count) - ->schema([ - static::getCheckboxListFormComponent('custom_permissions', $options), - ]); - } - - public static function getTabFormComponentForSimpleResourcePermissionsView(): Component - { - $options = FilamentShield::getAllResourcePermissions(); - $count = count($options); - - return Forms\Components\Tabs\Tab::make('resources') - ->label(__('filament-shield::filament-shield.resources')) - ->visible(fn (): bool => (bool) Utils::isResourceEntityEnabled() && $count > 0) - ->badge($count) - ->schema([ - static::getCheckboxListFormComponent('resources_tab', $options), - ]); - } - - public static function getCheckboxListFormComponent(string $name, array $options, bool $searchable = true): Component - { - return Forms\Components\CheckboxList::make($name) - ->label('') - ->options(fn (): array => $options) - ->searchable($searchable) - ->afterStateHydrated( - fn (Component $component, string $operation, ?Model $record) => static::setPermissionStateForRecordPermissions( - component: $component, - operation: $operation, - permissions: $options, - record: $record - ) - ) - ->dehydrated(fn ($state) => ! blank($state)) - ->bulkToggleable() - ->gridDirection('row') - ->columns(static::shield()->getCheckboxListColumns()) - ->columnSpan(static::shield()->getCheckboxListColumnSpan()); - } - - public static function shield(): FilamentShieldPlugin - { - return FilamentShieldPlugin::get(); - } } diff --git a/src/Resources/RoleResource/Pages/CreateRole.php b/src/Resources/RoleResource/Pages/CreateRole.php index 600171f3..5370ce2f 100644 --- a/src/Resources/RoleResource/Pages/CreateRole.php +++ b/src/Resources/RoleResource/Pages/CreateRole.php @@ -18,12 +18,16 @@ protected function mutateFormDataBeforeCreate(array $data): array { $this->permissions = collect($data) ->filter(function ($permission, $key) { - return ! in_array($key, ['name', 'guard_name', 'select_all']); + return ! in_array($key, ['name', 'guard_name', 'select_all', 'team_id']); }) ->values() ->flatten() ->unique(); + if (Arr::has($data, 'team_id')) { + return Arr::only($data, ['name', 'guard_name', 'team_id']); + } + return Arr::only($data, ['name', 'guard_name']); } diff --git a/src/Resources/RoleResource/Pages/EditRole.php b/src/Resources/RoleResource/Pages/EditRole.php index 76f0bb80..fbe45243 100644 --- a/src/Resources/RoleResource/Pages/EditRole.php +++ b/src/Resources/RoleResource/Pages/EditRole.php @@ -26,12 +26,16 @@ protected function mutateFormDataBeforeSave(array $data): array { $this->permissions = collect($data) ->filter(function ($permission, $key) { - return ! in_array($key, ['name', 'guard_name', 'select_all']); + return ! in_array($key, ['name', 'guard_name', 'select_all', 'team_id']); }) ->values() ->flatten() ->unique(); + if (Arr::has($data, 'team_id')) { + return Arr::only($data, ['name', 'guard_name', 'team_id']); + } + return Arr::only($data, ['name', 'guard_name']); } diff --git a/src/Stringer.php b/src/Stringer.php new file mode 100644 index 00000000..380b5de4 --- /dev/null +++ b/src/Stringer.php @@ -0,0 +1,502 @@ + $filePath]); + } + + public function __construct(string $filePath) + { + $this->filePath = $filePath; + $this->content = file_get_contents($filePath) ?: ''; + } + + protected function findLine(string $needle): ?array + { + // Search for the needle and return the line and its indentation + $startPos = strpos($this->content, $needle); + if ($startPos === false) { + return null; // Not found + } + + // Get the start of the line and calculate indentation + $lineStartPos = strrpos(substr($this->content, 0, $startPos), PHP_EOL) ?: 0; + $lineEndPos = strpos($this->content, PHP_EOL, $startPos) ?: strlen($this->content); + + $line = substr($this->content, $lineStartPos, $lineEndPos - $lineStartPos); + $indentation = preg_replace('/\S.*/', '', $line); // Capture indentation + + return [ + 'start' => $lineStartPos, + 'end' => $lineEndPos, + 'indentation' => $indentation, + ]; + } + + public function prepend(string $needle, string $contentToPrepend, bool $beforeBlock = false): static + { + if (! $this->contains($needle)) { + return $this; // Needle not found + } + + if ($beforeBlock) { + // Find the starting position of the method + $startPos = strpos($this->content, $needle); + if ($startPos === false) { + return $this; // Needle not found + } + + // Find the opening parenthesis position + $openingParenPos = strpos($this->content, '(', $startPos); + if ($openingParenPos === false) { + return $this; // No opening parenthesis found + } + + // Find the closing parenthesis + $closingParenPos = $this->findClosingParen($openingParenPos); + if (is_null($closingParenPos)) { + return $this; // No closing parenthesis found + } + + // Get the line indentation + $lineInfo = $this->findLine($needle); + $indentation = $lineInfo['indentation'] . $this->getIndentation(); + + // Format the new content based on the newLine flag + $formattedReplacement = $this->addNewLine + ? PHP_EOL . $indentation . trim($contentToPrepend) + : $indentation . trim($contentToPrepend); + + $this->addNewLine = false; // Reset flag + + // Insert the formatted replacement before the opening parenthesis + $this->content = substr_replace($this->content, $formattedReplacement, $openingParenPos, 0); + } else { + // Normal prepend logic + if ($lineInfo = $this->findLine($needle)) { + // Prepend the content with proper indentation + $newContent = $lineInfo['indentation'] . $this->getIndentation() . trim($contentToPrepend); + if ($this->addNewLine) { + $newContent = PHP_EOL . $newContent; + $this->addNewLine = false; // Reset the flag + } + $this->content = substr_replace( + $this->content, + $newContent, + $lineInfo['start'], + 0 + ); + } + } + + return $this; + } + + public function append(string $needle, string $contentToAppend, bool $afterBlock = false): static + { + if (! $this->contains($needle)) { + return $this; // Needle not found + } + + if ($afterBlock) { + // Find the starting position of the method + $startPos = strpos($this->content, $needle); + if ($startPos === false) { + return $this; // Needle not found + } + + // Find the opening parenthesis position + $openingParenPos = strpos($this->content, '(', $startPos); + if ($openingParenPos === false) { + return $this; // No opening parenthesis found + } + + // Find the closing parenthesis + $closingParenPos = $this->findClosingParen($openingParenPos); + if (is_null($closingParenPos)) { + return $this; // No closing parenthesis found + } + + // Get the line indentation + $lineInfo = $this->findLine($needle); + $indentation = $lineInfo['indentation'] . $this->getIndentation(); + + // Format the new content based on the newLine flag + $formattedReplacement = $this->addNewLine + ? $indentation . trim($contentToAppend) . PHP_EOL + : $indentation . trim($contentToAppend); + + $this->addNewLine = false; // Reset flag + + // If the closing parenthesis has a semicolon, move it to a new line with indentation + if ($this->content[$closingParenPos + 1] === ';') { + $this->content = substr_replace( + $this->content, + PHP_EOL . $indentation . ';', + $closingParenPos + 1, + 0 + ); + $closingParenPos += strlen(PHP_EOL . $indentation . ';'); // Adjust closing position + } + + // Insert the formatted replacement after the closing parenthesis + $this->content = substr_replace($this->content, $formattedReplacement, $closingParenPos + 1, 0); + } else { + // Normal append logic + if ($lineInfo = $this->findLine($needle)) { + // Append the content with proper indentation + $newContent = $lineInfo['indentation'] . $this->getIndentation() . trim($contentToAppend); + if ($this->addNewLine) { + $newContent = $newContent . PHP_EOL; + $this->addNewLine = false; // Reset the flag + } + $this->content = substr_replace( + $this->content, + $newContent, + $lineInfo['end'], + 0 + ); + } + } + + return $this; + } + + protected function findClosingParen(int $openingParenPos): ?int + { + $stack = 0; + $length = strlen($this->content); + + for ($i = $openingParenPos; $i < $length; $i++) { + if ($this->content[$i] === '(') { + $stack++; + } elseif ($this->content[$i] === ')') { + $stack--; + if ($stack === 0) { + return $i; // Found the closing parenthesis + } + } + } + + return null; // Closing parenthesis not found + } + + public function replace(string $needle, string $replacement): static + { + if ($lineInfo = $this->findLine($needle)) { + // Replace the entire line containing the needle + $this->content = substr_replace( + $this->content, + $lineInfo['indentation'] . trim($replacement), + $lineInfo['start'], + strlen(substr($this->content, $lineInfo['start'], $lineInfo['end'] - $lineInfo['start'])) + ); + } + + return $this; + } + + public function newLine(): static + { + $this->addNewLine = true; // Set the flag to add a new line + + return $this; + } + + public function indent(int $level): static + { + $this->currentIndentLevel += $level; + + return $this; + } + + public function getIndentation(): string + { + return str_repeat(' ', $this->baseIndentLevel + $this->currentIndentLevel); + } + + public function contains(string $needle): bool + { + // Check if the needle has a wildcard for partial matching + $isPartialMatch = str_starts_with($needle, '*') || str_ends_with($needle, '*'); + + if ($isPartialMatch) { + // Strip the `*` characters for partial matching + $needle = trim($needle, '*'); + + return (bool) preg_match('/' . preg_quote($needle, '/') . '/', $this->content); + } else { + // Perform an exact search + return str_contains($this->content, $needle); + } + } + + public function save(): bool + { + return (bool) file_put_contents($this->filePath, $this->content); + } + + public function getContent(): string + { + return $this->content; + } + + public function prependBeforeLast(string $needle, string $replacement): static + { + $lastPos = strrpos($this->content, $needle); + + if ($lastPos !== false) { + $lineStartPos = strrpos(substr($this->content, 0, $lastPos), PHP_EOL) ?: 0; + $line = substr($this->content, $lineStartPos, $lastPos - $lineStartPos); + + preg_match('/^\s*/', $line, $matches); + $originalIndentation = $matches[0] ?? ''; + + $formattedReplacement = $this->getIndentation() . trim($replacement); + if ($this->addNewLine) { + $formattedReplacement = PHP_EOL . $formattedReplacement . PHP_EOL; + } + + $this->addNewLine = false; + + $this->content = substr_replace($this->content, $originalIndentation . $formattedReplacement, $lineStartPos, 0); + } + + return $this; + } + + protected function findMethodDeclaration(string $needle): ?array + { + $lines = explode(PHP_EOL, $this->content); + $normalizedNeedle = preg_replace('/\s+/', ' ', trim($needle)); + + for ($i = 0; $i < count($lines); $i++) { + $currentLine = trim($lines[$i]); + $nextLine = isset($lines[$i + 1]) ? trim($lines[$i + 1]) : ''; + + // Check if current line contains the method declaration + // and next line contains the opening brace + if (str_contains(preg_replace('/\s+/', ' ', $currentLine), $normalizedNeedle) + && str_contains($nextLine, '{')) { + + $startPos = 0; + for ($j = 0; $j < $i; $j++) { + $startPos += strlen($lines[$j]) + strlen(PHP_EOL); + } + + $endPos = $startPos + strlen($lines[$i]) + strlen(PHP_EOL) + strlen($lines[$i + 1]); + $indentation = preg_replace('/\S.*/', '', $lines[$i]); + + // Find the closing brace position + $braceLevel = 0; + $methodEndLine = $i + 1; + for ($j = $i + 1; $j < count($lines); $j++) { + if (str_contains($lines[$j], '{')) { + $braceLevel++; + } + if (str_contains($lines[$j], '}')) { + $braceLevel--; + if ($braceLevel === 0) { + $methodEndLine = $j; + + break; + } + } + } + + $methodEndPos = $startPos; + for ($j = $i; $j <= $methodEndLine; $j++) { + $methodEndPos += strlen($lines[$j]) + strlen(PHP_EOL); + } + + return [ + 'start' => $startPos, + 'end' => $endPos, + 'method_end' => $methodEndPos, + 'indentation' => $indentation, + 'is_method' => true, + 'opening_brace_line' => $i + 1, + 'closing_brace_line' => $methodEndLine, + ]; + } + } + + // Fallback to regular findLine if method declaration pattern isn't found + return $this->findLine($needle); + } + + public function findChainedBlock(string $block): ?array + { + // Normalize the search block by removing extra whitespace + $normalizedBlock = preg_replace('/\s+/', ' ', trim($block)); + + // Split the block into individual method calls + $methodCalls = array_map('trim', explode('->', $normalizedBlock)); + + $lines = explode(PHP_EOL, $this->content); + $contentLength = count($lines); + + for ($i = 0; $i < $contentLength; $i++) { + $matchFound = true; + $currentMethodIndex = 0; + $startLine = $i; + $endLine = $i; + + // Try to match all method calls in sequence + while ($currentMethodIndex < count($methodCalls) && $endLine < $contentLength) { + $currentLine = trim($lines[$endLine]); + $normalizedLine = preg_replace('/\s+/', ' ', $currentLine); + + // Check if current line contains the current method call + if (str_contains($normalizedLine, trim($methodCalls[$currentMethodIndex]))) { + $currentMethodIndex++; + $endLine++; + } elseif (! empty($currentLine)) { + // If we find a non-empty line that doesn't match, break + $matchFound = false; + + break; + } else { + // Skip empty lines + $endLine++; + } + } + + if ($matchFound && $currentMethodIndex === count($methodCalls)) { + // Calculate positions + $startPos = 0; + for ($j = 0; $j < $startLine; $j++) { + $startPos += strlen($lines[$j]) + strlen(PHP_EOL); + } + + $endPos = $startPos; + for ($j = $startLine; $j < $endLine; $j++) { + $endPos += strlen($lines[$j]) + strlen(PHP_EOL); + } + + $indentation = preg_replace('/\S.*/', '', $lines[$startLine]); + + return [ + 'start' => $startPos, + 'end' => $endPos, + 'indentation' => $indentation, + 'is_block' => true, + ]; + } + } + + return null; + } + + public function containsChainedBlock(string $block): bool + { + return $this->findChainedBlock($block) !== null; + } + + public function appendBlock(string $needle, string $contentToAppend, bool $afterBlock = false): static + { + if (! $this->contains($needle)) { + return $this; + } + + // Use findMethodDeclaration for better method handling + $lineInfo = $this->findMethodDeclaration($needle); + + if (! $lineInfo) { + return $this; + } + + if ($afterBlock && isset($lineInfo['is_method']) && $lineInfo['is_method']) { + // For method declarations, get the lines of content + $lines = explode(PHP_EOL, $this->content); + + // Calculate proper indentation + $methodIndent = $lineInfo['indentation']; + $contentIndent = $methodIndent . str_repeat(' ', 4); // One level deeper than method + + // Format the content to append + $contentLines = explode(PHP_EOL, trim($contentToAppend)); + $formattedContent = ''; + foreach ($contentLines as $index => $line) { + $trimmedLine = trim($line); + if (empty($trimmedLine)) { + continue; + } + + $formattedContent .= ($index > 0 ? PHP_EOL . $methodIndent : '') . $contentIndent . $trimmedLine; + } + + // Add new line if flag is set + if ($this->addNewLine) { + $formattedContent = $formattedContent . PHP_EOL; + $this->addNewLine = false; + } + + // Find position after opening brace + $insertPos = 0; + for ($i = 0; $i <= $lineInfo['opening_brace_line']; $i++) { + $insertPos += strlen($lines[$i]) + strlen(PHP_EOL); + } + + // Insert the formatted content + $this->content = substr_replace( + $this->content, + $formattedContent . PHP_EOL . "\n", + $insertPos, + 0 + ); + } else { + // Original append logic + $newContent = $lineInfo['indentation'] . $this->getIndentation() . trim($contentToAppend); + if ($this->addNewLine) { + $newContent = PHP_EOL . $newContent; + $this->addNewLine = false; + } + + $this->content = substr_replace( + $this->content, + $newContent, + $lineInfo['end'], + 0 + ); + } + + return $this; + } + + public function deleteLine(string $needle): static + { + if ($lineInfo = $this->findLine($needle)) { + $this->content = substr_replace( + $this->content, + '', + $lineInfo['start'], + $lineInfo['end'] - $lineInfo['start'] + ); + } + + return $this; + } +} diff --git a/src/Support/Permission.php b/src/Support/Permission.php new file mode 100644 index 00000000..532a40b0 --- /dev/null +++ b/src/Support/Permission.php @@ -0,0 +1,10 @@ +getAuthGuard() ?? ''; } - public static function isResourcePublished(): bool + public static function isResourcePublished(Panel $panel): bool { - $roleResourcePath = app_path((string) Str::of('Filament\\Resources\\Shield\\RoleResource.php')->replace('\\', '/')); - - $filesystem = new Filesystem; - - return (bool) $filesystem->exists($roleResourcePath); + return str( + string: collect(value: $panel->getResources()) + ->values() + ->join(',') + ) + ->contains('RoleResource'); } public static function getResourceSlug(): string @@ -220,12 +223,14 @@ public static function getResourcePermissionPrefixes(string $resourceFQCN): arra public static function getRoleModel(): string { - return config('permission.models.role', 'Spatie\\Permission\\Models\\Role'); + return app(PermissionRegistrar::class) + ->getRoleClass(); } public static function getPermissionModel(): string { - return config('permission.models.permission', 'Spatie\\Permission\\Models\\Permission'); + return app(PermissionRegistrar::class) + ->getPermissionClass(); } public static function discoverAllResources(): bool @@ -256,4 +261,9 @@ protected static function isRolePolicyGenerated(): bool return (bool) $filesystem->exists(app_path(static::getPolicyPath() . DIRECTORY_SEPARATOR . 'RolePolicy.php')); } + + public static function isTeamFeatureEnabled(): bool + { + return (bool) config()->get('permission.teams', false); + } } diff --git a/src/Traits/HasPanelShield.php b/src/Traits/HasPanelShield.php index 5f34a8d2..db98fbab 100644 --- a/src/Traits/HasPanelShield.php +++ b/src/Traits/HasPanelShield.php @@ -11,13 +11,15 @@ trait HasPanelShield { public static function bootHasPanelShield() { - if (Utils::isPanelUserRoleEnabled()) { + if (! app()->runningInConsole()) { + if (Utils::isPanelUserRoleEnabled()) { - Utils::createPanelUserRole(); + Utils::createPanelUserRole(); - static::created(fn ($user) => $user->assignRole(Utils::getPanelUserRoleName())); + static::created(fn ($user) => $user->assignRole(Utils::getPanelUserRoleName())); - static::deleting(fn ($user) => $user->removeRole(Utils::getPanelUserRoleName())); + static::deleting(fn ($user) => $user->removeRole(Utils::getPanelUserRoleName())); + } } } diff --git a/src/Traits/HasShieldFormComponents.php b/src/Traits/HasShieldFormComponents.php new file mode 100644 index 00000000..9a7f91f6 --- /dev/null +++ b/src/Traits/HasShieldFormComponents.php @@ -0,0 +1,229 @@ +contained() + ->tabs([ + static::getTabFormComponentForResources(), + static::getTabFormComponentForPage(), + static::getTabFormComponentForWidget(), + static::getTabFormComponentForCustomPermissions(), + ]) + ->columnSpan('full'); + } + + public static function getResourceEntitiesSchema(): ?array + { + return collect(FilamentShield::getResources()) + ->sortKeys() + ->map(function ($entity) { + $sectionLabel = strval( + static::shield()->hasLocalizedPermissionLabels() + ? FilamentShield::getLocalizedResourceLabel($entity['fqcn']) + : $entity['model'] + ); + + return Forms\Components\Section::make($sectionLabel) + ->description(fn () => new HtmlString('' . Utils::showModelPath($entity['fqcn']) . '')) + ->compact() + ->schema([ + static::getCheckBoxListComponentForResource($entity), + ]) + ->columnSpan(static::shield()->getSectionColumnSpan()) + ->collapsible(); + }) + ->toArray(); + } + + public static function getResourceTabBadgeCount(): ?int + { + return collect(FilamentShield::getResources()) + ->map(fn ($resource) => count(static::getResourcePermissionOptions($resource))) + ->sum(); + } + + public static function getResourcePermissionOptions(array $entity): array + { + return collect(Utils::getResourcePermissionPrefixes($entity['fqcn'])) + ->flatMap(function ($permission) use ($entity) { + $name = $permission . '_' . $entity['resource']; + $label = static::shield()->hasLocalizedPermissionLabels() + ? FilamentShield::getLocalizedResourcePermissionLabel($permission) + : $name; + + return [ + $name => $label, + ]; + }) + ->toArray(); + } + + public static function setPermissionStateForRecordPermissions(Component $component, string $operation, array $permissions, ?Model $record): void + { + if (in_array($operation, ['edit', 'view'])) { + + if (blank($record)) { + return; + } + if ($component->isVisible() && count($permissions) > 0) { + $component->state( + collect($permissions) + /** @phpstan-ignore-next-line */ + ->filter(fn ($value, $key) => $record->checkPermissionTo($key)) + ->keys() + ->toArray() + ); + } + } + } + + public static function getPageOptions(): array + { + return collect(FilamentShield::getPages()) + ->flatMap(fn ($page) => [ + $page['permission'] => static::shield()->hasLocalizedPermissionLabels() + ? FilamentShield::getLocalizedPageLabel($page['class']) + : $page['permission'], + ]) + ->toArray(); + } + + public static function getWidgetOptions(): array + { + return collect(FilamentShield::getWidgets()) + ->flatMap(fn ($widget) => [ + $widget['permission'] => static::shield()->hasLocalizedPermissionLabels() + ? FilamentShield::getLocalizedWidgetLabel($widget['class']) + : $widget['permission'], + ]) + ->toArray(); + } + + public static function getCustomPermissionOptions(): ?array + { + return FilamentShield::getCustomPermissions() + ->mapWithKeys(fn ($customPermission) => [ + $customPermission => static::shield()->hasLocalizedPermissionLabels() ? str($customPermission)->headline()->toString() : $customPermission, + ]) + ->toArray(); + } + + public static function getTabFormComponentForResources(): Component + { + return static::shield()->hasSimpleResourcePermissionView() + ? static::getTabFormComponentForSimpleResourcePermissionsView() + : Forms\Components\Tabs\Tab::make('resources') + ->label(__('filament-shield::filament-shield.resources')) + ->visible(fn (): bool => (bool) Utils::isResourceEntityEnabled()) + ->badge(static::getResourceTabBadgeCount()) + ->schema([ + Forms\Components\Grid::make() + ->schema(static::getResourceEntitiesSchema()) + ->columns(static::shield()->getGridColumns()), + ]); + } + + public static function getCheckBoxListComponentForResource(array $entity): Component + { + $permissionsArray = static::getResourcePermissionOptions($entity); + + return static::getCheckboxListFormComponent($entity['resource'], $permissionsArray, false); + } + + public static function getTabFormComponentForPage(): Component + { + $options = static::getPageOptions(); + $count = count($options); + + return Forms\Components\Tabs\Tab::make('pages') + ->label(__('filament-shield::filament-shield.pages')) + ->visible(fn (): bool => (bool) Utils::isPageEntityEnabled() && $count > 0) + ->badge($count) + ->schema([ + static::getCheckboxListFormComponent('pages_tab', $options), + ]); + } + + public static function getTabFormComponentForWidget(): Component + { + $options = static::getWidgetOptions(); + $count = count($options); + + return Forms\Components\Tabs\Tab::make('widgets') + ->label(__('filament-shield::filament-shield.widgets')) + ->visible(fn (): bool => (bool) Utils::isWidgetEntityEnabled() && $count > 0) + ->badge($count) + ->schema([ + static::getCheckboxListFormComponent('widgets_tab', $options), + ]); + } + + public static function getTabFormComponentForCustomPermissions(): Component + { + $options = static::getCustomPermissionOptions(); + $count = count($options); + + return Forms\Components\Tabs\Tab::make('custom') + ->label(__('filament-shield::filament-shield.custom')) + ->visible(fn (): bool => (bool) Utils::isCustomPermissionEntityEnabled() && $count > 0) + ->badge($count) + ->schema([ + static::getCheckboxListFormComponent('custom_permissions', $options), + ]); + } + + public static function getTabFormComponentForSimpleResourcePermissionsView(): Component + { + $options = FilamentShield::getAllResourcePermissions(); + $count = count($options); + + return Forms\Components\Tabs\Tab::make('resources') + ->label(__('filament-shield::filament-shield.resources')) + ->visible(fn (): bool => (bool) Utils::isResourceEntityEnabled() && $count > 0) + ->badge($count) + ->schema([ + static::getCheckboxListFormComponent('resources_tab', $options), + ]); + } + + public static function getCheckboxListFormComponent(string $name, array $options, bool $searchable = true): Component + { + return Forms\Components\CheckboxList::make($name) + ->label('') + ->options(fn (): array => $options) + ->searchable($searchable) + ->afterStateHydrated( + fn (Component $component, string $operation, ?Model $record) => static::setPermissionStateForRecordPermissions( + component: $component, + operation: $operation, + permissions: $options, + record: $record + ) + ) + ->dehydrated(fn ($state) => ! blank($state)) + ->bulkToggleable() + ->gridDirection('row') + ->columns(static::shield()->getCheckboxListColumns()) + ->columnSpan(static::shield()->getCheckboxListColumnSpan()); + } + + public static function shield(): FilamentShieldPlugin + { + return FilamentShieldPlugin::get(); + } +} diff --git a/tests/DatabaseTest.php b/tests/DatabaseTest.php index ecc2beb3..d16d2c6c 100644 --- a/tests/DatabaseTest.php +++ b/tests/DatabaseTest.php @@ -10,13 +10,8 @@ it('can check if the permission name can be configured using the closure', function () { $resource = RoleResource::class; - // FilamentShield::configurePermissionIdentifierUsing(fn () => str($resource)->afterLast('Resources\\')->replace('\\', '')->headline()->snake()->replace('_', '.')); - // expect(FilamentShield::getPermissionIdentifier($resource))->toBe('role.resource'); - // FilamentShield::configurePermissionIdentifierUsing(fn () => str($resource)->afterLast('Resources\\')->replace('\\', '')->headline()->snake()->replace('_', '::')); - // expect(FilamentShield::getPermissionIdentifier($resource))->toBe('role::resource'); - FilamentShield::configurePermissionIdentifierUsing( - fn ($resource) => str($resource::getModel()) + fn ($resource) => str('Spatie\\Permission\\Models\\Role') ->afterLast('\\') ->lower() ->toString()