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
```
+## 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()