diff --git a/app/Enums/ArticleCategoryEnum.php b/app/Enums/ArticleCategoryEnum.php new file mode 100644 index 000000000..f0dfbfab8 --- /dev/null +++ b/app/Enums/ArticleCategoryEnum.php @@ -0,0 +1,10 @@ + 'Super Administrator', self::Admin => 'Administrator', + self::Editor => 'Editor', }; } } diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php new file mode 100644 index 000000000..f1889ccca --- /dev/null +++ b/app/Filament/Resources/ArticleResource.php @@ -0,0 +1,126 @@ +schema([ + TextInput::make('title')->required()->columnSpan('full'), + Select::make('category') + ->options([ + ArticleCategoryEnum::News->value => Str::title(ArticleCategoryEnum::News->value), + ]) + ->default(ArticleCategoryEnum::News->value) + ->required(), + Textarea::make('meta_description')->nullable()->autosize()->columnSpan('full'), + Textarea::make('content')->required()->autosize()->columnSpan('full'), + Select::make('user_id') + ->relationship( + name: 'user', + modifyQueryUsing: fn ($query) => $query->managers()->orderBy('username')->orderBy('email') + ) + ->getOptionLabelFromRecordUsing(fn (User $user) => $user->username ?? $user->email ?? 'ID '.$user->id) + ->required(), + DatePicker::make('published_at')->nullable(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('title') + ->label('Title') + ->sortable() + ->searchable(), + TextColumn::make('category') + ->label('Category') + ->sortable() + ->searchable(), + + TextColumn::make('published_at') + ->label('Date Published') + ->date() + ->sortable(), + + TextColumn::make('created_at') + ->label('Date Created') + ->dateTime() + ->sortable(), + + ]) + ->filters([ + // + ]) + ->recordUrl(fn (Article $article) => ArticleResource::getUrl('view', ['record' => $article])) + ->actions([ + ViewAction::make(), + ]) + ->emptyStateActions([ + CreateAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListArticles::route('/'), + 'create' => CreateArticle::route('/create'), + 'view' => ViewArticle::route('/{record}'), + 'edit' => EditArticle::route('/{record}/edit'), + ]; + } + + /** + * @return Builder
+ */ + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + + public static function shouldSkipAuthorization(): bool + { + return app()->isLocal(); + } +} diff --git a/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php b/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php new file mode 100644 index 000000000..3ccbef2bd --- /dev/null +++ b/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php @@ -0,0 +1,13 @@ + ArticleCategoryEnum::class, + 'published_at' => 'timestamp', + ]; + /** * @return BelongsToMany */ diff --git a/app/Models/User.php b/app/Models/User.php index 2fdc1dfd8..597675fe9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -166,4 +166,15 @@ public function canAccessPanel(Panel $panel): bool return false; } } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeManagers(Builder $query): Builder + { + return $query->whereHas('roles', function ($query) { + $query->whereIn('name', [Role::Admin->value, Role::Superadmin->value, Role::Editor->value]); + }); + } } diff --git a/app/Policies/ArticlePolicy.php b/app/Policies/ArticlePolicy.php new file mode 100644 index 000000000..c760e9a72 --- /dev/null +++ b/app/Policies/ArticlePolicy.php @@ -0,0 +1,57 @@ +hasPermissionTo('article:viewAny', 'admin'); + } + + public function view(User $user, Article $article): bool + { + // If users can view any, can view single article + return $this->viewAny($user); + } + + public function create(User $user): bool + { + return $user->hasPermissionTo('article:create', 'admin'); + } + + public function update(User $user, Article $article): bool + { + if ($user->hasPermissionTo('article:updateAny', 'admin')) { + return true; + } + + // If users can create, they can update their own + return $this->create($user) && ($user->is($article->user)); + } + + public function delete(User $user, Article $article): bool + { + if ($user->hasPermissionTo('article:deleteAny', 'admin')) { + return true; + } + + // If users can create, they can delete their own + return $this->create($user) && ($user->is($article->user)); + } + + public function restore(User $user, Article $article): bool + { + return $user->hasPermissionTo('article:restore', 'admin'); + } + + public function forceDelete(User $user, Article $article): bool + { + return $user->hasPermissionTo('article:forceDelete', 'admin'); + } +} diff --git a/config/permission.php b/config/permission.php index b37be92a2..1e2adf82b 100644 --- a/config/permission.php +++ b/config/permission.php @@ -10,17 +10,31 @@ 'user:view' => 'View User', 'user:assignRole' => 'Assign User Role', 'user:assignPermissions' => 'Assign User Permissions', + 'article:create' => 'Create Article', + 'article:viewAny' => 'View Article', + 'article:updateAny' => 'Update any Article', + 'article:deleteAny' => 'Delete any Article', + 'article:restore' => 'Restore Deleted Article', + 'article:forceDelete' => 'Force Delete Article', 'admin:access' => 'Allow access to Admin panel', ], 'roles' => [ Role::Superadmin->value => [ 'user:viewAny', 'user:view', 'user:restore', 'user:assignRole', 'user:assignPermissions', + 'article:viewAny', 'article:create', 'article:updateAny', 'article:deleteAny', 'article:restore', 'article:forceDelete', 'admin:access', ], Role::Admin->value => [ 'user:viewAny', 'user:view', 'user:assignRole', + 'article:viewAny', 'article:create', 'article:updateAny', 'article:deleteAny', 'article:restore', 'article:forceDelete', + 'admin:access', + ], + + Role::Editor->value => [ + // For the moment `article:create` also allows to update and delete own articles + 'article:viewAny', 'article:create', 'admin:access', ], ], diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index 07bc43a1b..92e03f369 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\ArticleCategoryEnum; use App\Models\Article; use App\Models\User; use Database\Factories\Traits\RandomTimestamps; @@ -25,7 +26,7 @@ public function definition(): array { return [ 'title' => fake()->name(), - 'category' => fake()->name(), + 'category' => fake()->randomElement([ArticleCategoryEnum::News->value]), 'published_at' => fake()->date(), 'meta_description' => fake()->text(), 'content' => fake()->text(), diff --git a/database/seeders/ArticleSeeder.php b/database/seeders/ArticleSeeder.php index d155e5635..514181b9a 100644 --- a/database/seeders/ArticleSeeder.php +++ b/database/seeders/ArticleSeeder.php @@ -28,8 +28,8 @@ public function run(): void $imageUrl = fake()->imageUrl(640, 480, null, false); $article->addMediaFromUrl($imageUrl)->toMediaCollection(); - $collections = Collection::factory(2)->createMany([ - 'network' => $network->id, + $collections = Collection::factory()->count(2)->create([ + 'network_id' => $network->id, ]); $article->collections()->attach($collections, ['order_index' => 1]); diff --git a/tests/App/Models/UserTest.php b/tests/App/Models/UserTest.php index 7b0bbe299..9e800674b 100644 --- a/tests/App/Models/UserTest.php +++ b/tests/App/Models/UserTest.php @@ -3,10 +3,12 @@ declare(strict_types=1); use App\Enums\CurrencyCode; +use App\Enums\Role; use App\Models\Collection; use App\Models\Gallery; use App\Models\Network; use App\Models\Nft; +use App\Models\Role as RoleModel; use App\Models\User; use App\Models\Wallet; use Filament\Panel; @@ -297,3 +299,34 @@ expect($user->canAccessPanel(new Panel))->toBeTrue(); }); + +it('filters managers', function () { + setUpPermissions(); + + $user = User::factory()->create(); + $superadmin = User::factory()->create(); + $admin = User::factory()->create(); + $editor = User::factory()->create(); + + $editor->assignRole([ + RoleModel::where('name', Role::Editor->value)->where('guard_name', 'admin')->firstOrFail(), + ])->save(); + + $admin->assignRole([ + RoleModel::where('name', Role::Admin->value)->where('guard_name', 'admin')->firstOrFail(), + ])->save(); + + $superadmin->assignRole([ + RoleModel::where('name', Role::Superadmin->value)->where('guard_name', 'admin')->firstOrFail(), + ])->save(); + + $managers = User::managers()->get(); + + expect($managers)->toHaveCount(3); + + expect($managers->pluck('id')->toArray())->toEqualCanonicalizing([ + $superadmin->id, + $admin->id, + $editor->id, + ]); +}); diff --git a/tests/App/Policies/ArticlePolicyTest.php b/tests/App/Policies/ArticlePolicyTest.php new file mode 100644 index 000000000..1eab17b8d --- /dev/null +++ b/tests/App/Policies/ArticlePolicyTest.php @@ -0,0 +1,155 @@ +instance = new ArticlePolicy(); + + $this->user = User::factory()->create(); + $this->admin = User::factory()->create(); + $this->editor = User::factory()->create(); + + $this->editor->assignRole([ + RoleModel::where('name', Role::Editor->value)->where('guard_name', 'admin')->firstOrFail(), + ])->save(); + + $this->admin->assignRole([ + RoleModel::where('name', Role::Superadmin->value)->where('guard_name', 'admin')->firstOrFail(), + ])->save(); + +}); + +it('should not be able to view articles', function () { + expect($this->instance->viewAny($this->user))->toBeFalse(); + expect($this->user->hasPermissionTo('article:viewAny', 'admin'))->toBeFalse(); +}); + +it('should be able to view articles', function () { + expect($this->instance->viewAny($this->admin))->toBeTrue(); + expect($this->admin->hasPermissionTo('article:viewAny', 'admin'))->toBeTrue(); +}); + +it('should not be able to view a single article', function () { + $article = Article::factory()->create(); + + expect($this->user->hasPermissionTo('article:viewAny', 'admin'))->toBeFalse(); + expect($this->instance->view($this->user, $article))->toBeFalse(); +}); + +it('should be able to view a single article', function () { + $article = Article::factory()->create(); + + expect($this->admin->hasPermissionTo('article:viewAny', 'admin'))->toBeTrue(); + expect($this->instance->view($this->admin, $article))->toBeTrue(); +}); + +it('should be able to update own article', function () { + $article = Article::factory()->create([ + 'user_id' => $this->editor->id, + ]); + + expect($this->instance->update($this->editor, $article))->toBeTrue(); +}); + +it('should not be able to create articles', function () { + expect($this->instance->create($this->user))->toBeFalse(); +}); + +it('should be able to create articles', function () { + expect($this->editor->hasPermissionTo('article:create', 'admin'))->toBeTrue(); + + expect($this->instance->create($this->admin))->toBeTrue(); + expect($this->instance->create($this->editor))->toBeTrue(); +}); + +it('should not be able to update a single article', function () { + $article = Article::factory()->create(); + + expect($this->instance->update($this->user, $article))->toBeFalse(); + expect($this->instance->update($this->editor, $article))->toBeFalse(); +}); + +it('should be able to update a single article', function () { + $article = Article::factory()->create(); + + expect($this->instance->update($this->admin, $article))->toBeTrue(); +}); + +it('should be able to update a single article that owns', function () { + $article = Article::factory()->create([ + 'user_id' => $this->editor->id, + ]); + + expect($this->instance->update($this->editor, $article))->toBeTrue(); +}); + +it('should not be able to delete a single article', function () { + $article = Article::factory()->create(); + + expect($this->instance->delete($this->user, $article))->toBeFalse(); + expect($this->instance->delete($this->editor, $article))->toBeFalse(); +}); + +it('should be able to delete a single article', function () { + $article = Article::factory()->create(); + + expect($this->instance->delete($this->admin, $article))->toBeTrue(); +}); + +it('should be able to delete a single article that owns', function () { + $article = Article::factory()->create([ + 'user_id' => $this->editor->id, + ]); + + expect($this->instance->delete($this->editor, $article))->toBeTrue(); +}); + +it('should not be able to restore a single article', function () { + $article = Article::factory()->create(); + + expect($this->instance->restore($this->user, $article))->toBeFalse(); + expect($this->instance->restore($this->editor, $article))->toBeFalse(); +}); + +it('should be able to restore a single article', function () { + $article = Article::factory()->create(); + + expect($this->instance->restore($this->admin, $article))->toBeTrue(); +}); + +it('should not be able to restore an article', function () { + $article = Article::factory()->create([ + 'user_id' => $this->editor->id, + ]); + + expect($this->instance->restore($this->editor, $article))->toBeFalse(); +}); + +it('should not be able to forceDelete a single article', function () { + $article = Article::factory()->create(); + + expect($this->instance->forceDelete($this->user, $article))->toBeFalse(); + expect($this->instance->forceDelete($this->editor, $article))->toBeFalse(); +}); + +it('should be able to forceDelete a single article', function () { + $article = Article::factory()->create(); + + expect($this->instance->forceDelete($this->admin, $article))->toBeTrue(); +}); + +it('should not be able to forceDelete a single', function () { + $article = Article::factory()->create([ + 'user_id' => $this->editor->id, + ]); + + expect($this->instance->forceDelete($this->editor, $article))->toBeFalse(); +}); diff --git a/tests/App/Support/PermissionRepositoryTest.php b/tests/App/Support/PermissionRepositoryTest.php index ef007b407..8bf74238a 100644 --- a/tests/App/Support/PermissionRepositoryTest.php +++ b/tests/App/Support/PermissionRepositoryTest.php @@ -6,26 +6,26 @@ use Illuminate\Support\Facades\Config; it('should get all permissions', function () { - expect(PermissionRepository::all())->toHaveCount(6); + expect(PermissionRepository::all())->toHaveCount(12); }); it('should cache permissions and refresh every 5 days', function () { $config = config('permission.roles'); - expect(PermissionRepository::all())->toHaveCount(6); + expect(PermissionRepository::all())->toHaveCount(12); $config['User'] = ['user:test']; Config::set('permission.roles', $config); - expect(PermissionRepository::all())->toHaveCount(6); + expect(PermissionRepository::all())->toHaveCount(12); $this->travel(4)->days(); - expect(PermissionRepository::all())->toHaveCount(6); + expect(PermissionRepository::all())->toHaveCount(12); $this->travel(1)->days(); $this->travel(1)->minute(); - expect(PermissionRepository::all())->toHaveCount(7); + expect(PermissionRepository::all())->toHaveCount(13); }); diff --git a/tests/Helpers.php b/tests/Helpers.php index 32be702d5..3dbfa50c4 100644 --- a/tests/Helpers.php +++ b/tests/Helpers.php @@ -12,8 +12,6 @@ function setUpPermissions(string $guard = 'admin'): void $permissions = PermissionRepository::all(); $roles = config('permission.roles'); - app()[PermissionRegistrar::class]->forgetCachedPermissions(); - Permission::insert($permissions->map(fn ($permission) => [ 'name' => $permission, 'guard_name' => $guard, @@ -25,4 +23,6 @@ function setUpPermissions(string $guard = 'admin'): void 'name' => $role, 'guard_name' => $guard, ])->givePermissionTo($permissions)); + + app()[PermissionRegistrar::class]->forgetCachedPermissions(); }