diff --git a/app/Filament/Pages/Companies.php b/app/Filament/Pages/Companies.php index a06278a87..1342bc585 100644 --- a/app/Filament/Pages/Companies.php +++ b/app/Filament/Pages/Companies.php @@ -101,6 +101,7 @@ public function table(Table $table): Table ->label('Users') ->icon('heroicon-o-users') ->color('gray') + ->visible(fn (): bool => UserResource::canViewAny()) ->url(fn (array $record): string => UserResource::getUrl('index', [ 'tableSearch' => $record['domain'], ])), diff --git a/app/Filament/Resources/CourseResource/RelationManagers/ModulesRelationManager.php b/app/Filament/Resources/CourseResource/RelationManagers/ModulesRelationManager.php index 6ea944a92..3736655be 100644 --- a/app/Filament/Resources/CourseResource/RelationManagers/ModulesRelationManager.php +++ b/app/Filament/Resources/CourseResource/RelationManagers/ModulesRelationManager.php @@ -71,7 +71,7 @@ public function table(Table $table): Table ]) ->defaultSort('sort_order') ->reorderable('sort_order') - ->recordUrl(fn ($record) => CourseModuleResource::getUrl('edit', ['record' => $record])) + ->recordUrl(fn ($record) => CourseModuleResource::can('update', $record) ? CourseModuleResource::getUrl('edit', ['record' => $record]) : null) ->headerActions([ Actions\CreateAction::make(), ]) diff --git a/app/Filament/Resources/OpenCollectiveDonationResource.php b/app/Filament/Resources/OpenCollectiveDonationResource.php index 4558a24fb..fc8aff9bf 100644 --- a/app/Filament/Resources/OpenCollectiveDonationResource.php +++ b/app/Filament/Resources/OpenCollectiveDonationResource.php @@ -90,7 +90,7 @@ public static function infolist(Schema $schema): Schema Infolists\Components\TextEntry::make('user.email') ->label('Claimed By') ->placeholder('Not claimed') - ->url(fn ($record) => $record->user_id ? UserResource::getUrl('edit', ['record' => $record->user_id]) : null), + ->url(fn ($record) => $record->user && UserResource::can('update', $record->user) ? UserResource::getUrl('edit', ['record' => $record->user_id]) : null), ]), ]); } diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index 2de1aba46..5848c3dc3 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -344,7 +344,7 @@ public static function form(Schema $schema): Schema ? UserResource::getUrl('edit', ['record' => $record->user_id]) : null) ->openUrlInNewTab() - ->visible(fn (?Plugin $record) => $record?->user_id !== null), + ->visible(fn (?Plugin $record) => $record?->user !== null && UserResource::can('update', $record->user)), ), Forms\Components\Placeholder::make('created_at') diff --git a/app/Filament/Resources/SubscriptionItemResource.php b/app/Filament/Resources/SubscriptionItemResource.php index 235e3f474..160954ce5 100644 --- a/app/Filament/Resources/SubscriptionItemResource.php +++ b/app/Filament/Resources/SubscriptionItemResource.php @@ -62,7 +62,7 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('subscription.id') ->numeric() ->sortable() - ->url(fn ($record) => SubscriptionResource::getUrl('view', ['record' => $record->subscription])), + ->url(fn ($record) => $record->subscription && SubscriptionResource::can('view', $record->subscription) ? SubscriptionResource::getUrl('view', ['record' => $record->subscription]) : null), Tables\Columns\TextColumn::make('subscription.stripe_status') ->label('Subscription Status') ->badge() diff --git a/app/Filament/Resources/SubscriptionItemResource/Pages/ViewSubscriptionItem.php b/app/Filament/Resources/SubscriptionItemResource/Pages/ViewSubscriptionItem.php index 83b1d9c8b..3f7c3be6d 100644 --- a/app/Filament/Resources/SubscriptionItemResource/Pages/ViewSubscriptionItem.php +++ b/app/Filament/Resources/SubscriptionItemResource/Pages/ViewSubscriptionItem.php @@ -88,10 +88,10 @@ public function infolist(Schema $schema): Schema ->schema([ Components\TextEntry::make('subscription.id') ->label('Subscription ID') - ->url(fn ($record) => SubscriptionResource::getUrl('view', ['record' => $record->subscription_id])), + ->url(fn ($record) => $record->subscription && SubscriptionResource::can('view', $record->subscription) ? SubscriptionResource::getUrl('view', ['record' => $record->subscription_id]) : null), Components\TextEntry::make('subscription.user.email') ->label('User') - ->url(fn ($record) => UserResource::getUrl('edit', ['record' => $record->subscription_id])), + ->url(fn ($record) => $record->subscription?->user && UserResource::can('update', $record->subscription->user) ? UserResource::getUrl('edit', ['record' => $record->subscription->user_id]) : null), Components\TextEntry::make('stripe_id') ->label('Stripe ID') ->copyable(), diff --git a/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php b/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php index 7a1f5b52d..bf06ddd34 100644 --- a/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php +++ b/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php @@ -39,7 +39,7 @@ public function infolist(Schema $schema): Schema ->schema([ Components\TextEntry::make('user.email') ->label('User') - ->url(fn ($record) => UserResource::getUrl('edit', ['record' => $record->user_id])), + ->url(fn ($record) => $record->user && UserResource::can('update', $record->user) ? UserResource::getUrl('edit', ['record' => $record->user_id]) : null), Components\TextEntry::make('type'), Components\TextEntry::make('stripe_id') ->label('Stripe ID') diff --git a/app/Filament/Resources/SupportTicketResource.php b/app/Filament/Resources/SupportTicketResource.php index 292ac19a0..8602ed769 100644 --- a/app/Filament/Resources/SupportTicketResource.php +++ b/app/Filament/Resources/SupportTicketResource.php @@ -60,7 +60,7 @@ public static function infolist(Schema $schema): Schema Infolists\Components\TextEntry::make('user.email') ->label('User') ->formatStateUsing(fn (SupportTicket $record): string => trim(($record->user->name ?? '').' ('.$record->user->email.')')) - ->url(fn (SupportTicket $record): string => UserResource::getUrl('edit', ['record' => $record->user_id])), + ->url(fn (SupportTicket $record): ?string => $record->user && UserResource::can('update', $record->user) ? UserResource::getUrl('edit', ['record' => $record->user_id]) : null), Infolists\Components\TextEntry::make('created_at') ->label('Created') ->dateTime(), diff --git a/app/Filament/Resources/ThirdPartySaleResource.php b/app/Filament/Resources/ThirdPartySaleResource.php index c45af73f1..9668de169 100644 --- a/app/Filament/Resources/ThirdPartySaleResource.php +++ b/app/Filament/Resources/ThirdPartySaleResource.php @@ -168,7 +168,7 @@ public static function table(Table $table): Table ->url(fn (PluginLicense $record): ?string => $record->payout ? PluginPayoutResource::getUrl('view', ['record' => $record->payout]) : null) - ->visible(fn (PluginLicense $record): bool => $record->payout !== null), + ->visible(fn (PluginLicense $record): bool => $record->payout !== null && PluginPayoutResource::can('view', $record->payout)), ]) ->bulkActions([]) ->defaultSort('purchased_at', 'desc'); diff --git a/app/Filament/Resources/UserResource/RelationManagers/DeveloperPluginsRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/DeveloperPluginsRelationManager.php index 50874aa04..d9cf67278 100644 --- a/app/Filament/Resources/UserResource/RelationManagers/DeveloperPluginsRelationManager.php +++ b/app/Filament/Resources/UserResource/RelationManagers/DeveloperPluginsRelationManager.php @@ -44,6 +44,6 @@ public function table(Table $table): Table ->sortable(), ]) ->defaultSort('created_at', 'desc') - ->recordUrl(fn ($record) => PluginResource::getUrl('edit', ['record' => $record])); + ->recordUrl(fn ($record) => PluginResource::can('update', $record) ? PluginResource::getUrl('edit', ['record' => $record]) : null); } } diff --git a/app/Filament/Resources/WallOfLoveSubmissionResource/Pages/EditWallOfLoveSubmission.php b/app/Filament/Resources/WallOfLoveSubmissionResource/Pages/EditWallOfLoveSubmission.php index bda814849..1e1c83027 100644 --- a/app/Filament/Resources/WallOfLoveSubmissionResource/Pages/EditWallOfLoveSubmission.php +++ b/app/Filament/Resources/WallOfLoveSubmissionResource/Pages/EditWallOfLoveSubmission.php @@ -17,9 +17,8 @@ protected function getHeaderActions(): array Actions\DeleteAction::make(), Actions\Action::make('user') ->icon('heroicon-s-user') - ->action(function ($record) { - return redirect(UserResource::getUrl('edit', ['record' => $record])); - }), + ->visible(fn ($record): bool => $record->user !== null && UserResource::can('update', $record->user)) + ->url(fn ($record): string => UserResource::getUrl('edit', ['record' => $record->user_id])), ]; } } diff --git a/app/Http/Controllers/PluginDirectoryController.php b/app/Http/Controllers/PluginDirectoryController.php index 6ffc3d3df..96e994a70 100644 --- a/app/Http/Controllers/PluginDirectoryController.php +++ b/app/Http/Controllers/PluginDirectoryController.php @@ -54,13 +54,13 @@ public function show(string $vendor, string $package): View $user = Auth::user(); - $isAdmin = $user?->isAdmin() ?? false; + $isStaff = $user?->can('view', $plugin) ?? false; $isOwner = $user && $plugin->user_id === $user->id; - abort_unless(($plugin->isApproved() && $plugin->is_active) || $isAdmin || $isOwner, 404); + abort_unless(($plugin->isApproved() && $plugin->is_active) || $isStaff || $isOwner, 404); - // For paid plugins, check if user has an accessible price (admins and owners bypass) - if (! $isAdmin && ! $isOwner && $plugin->isPaid() && ! $plugin->hasAccessiblePriceFor($user)) { + // For paid plugins, check if user has an accessible price (staff and owners bypass) + if (! $isStaff && ! $isOwner && $plugin->isPaid() && ! $plugin->hasAccessiblePriceFor($user)) { abort(404); } @@ -81,7 +81,7 @@ public function show(string $vendor, string $package): View 'regularPrice' => $regularPrice, 'ultraOfferPrice' => $user?->hasActiveUltraSubscription() ? null : $plugin->getUltraPrice(), 'hasDiscount' => $bestPrice && $regularPrice && $bestPrice->id !== $regularPrice->id, - 'isAdminPreview' => (! $plugin->isApproved() || ! $plugin->is_active) && ($isAdmin || $isOwner), + 'isAdminPreview' => (! $plugin->isApproved() || ! $plugin->is_active) && ($isStaff || $isOwner), ]); } @@ -91,15 +91,15 @@ public function license(string $vendor, string $package): View $user = Auth::user(); - $isAdmin = $user?->isAdmin() ?? false; + $isStaff = $user?->can('view', $plugin) ?? false; $isOwner = $user && $plugin->user_id === $user->id; abort_unless($plugin->isPaid(), 404); abort_unless($plugin->license_html, 404); - abort_unless(($plugin->isApproved() && $plugin->is_active) || $isAdmin || $isOwner, 404); + abort_unless(($plugin->isApproved() && $plugin->is_active) || $isStaff || $isOwner, 404); - // For paid plugins, check if user has an accessible price (admins and owners bypass) - if (! $isAdmin && ! $isOwner && ! $plugin->hasAccessiblePriceFor($user)) { + // For paid plugins, check if user has an accessible price (staff and owners bypass) + if (! $isStaff && ! $isOwner && ! $plugin->hasAccessiblePriceFor($user)) { abort(404); } @@ -107,7 +107,7 @@ public function license(string $vendor, string $package): View return view('plugin-license', [ 'plugin' => $plugin, - 'isAdminPreview' => (! $plugin->isApproved() || ! $plugin->is_active) && ($isAdmin || $isOwner), + 'isAdminPreview' => (! $plugin->isApproved() || ! $plugin->is_active) && ($isStaff || $isOwner), ]); } diff --git a/app/Http/Controllers/ShowBlogController.php b/app/Http/Controllers/ShowBlogController.php index 8e9a1e745..2434ba5e4 100644 --- a/app/Http/Controllers/ShowBlogController.php +++ b/app/Http/Controllers/ShowBlogController.php @@ -35,7 +35,7 @@ public function feed(BlogFeed $feed): Response public function show(Article $article) { - abort_unless($article->isPublished() || $article->isScheduled() || auth()->user()?->isAdmin(), 404); + abort_unless($article->isPublished() || $article->isScheduled() || auth()->user()?->can('view', $article), 404); // Set SEO metadata for the article SEOTools::setTitle($article->title.' - Blog'); diff --git a/app/Http/Controllers/SupportTicketAttachmentController.php b/app/Http/Controllers/SupportTicketAttachmentController.php index eb72cff63..152661c3c 100644 --- a/app/Http/Controllers/SupportTicketAttachmentController.php +++ b/app/Http/Controllers/SupportTicketAttachmentController.php @@ -13,7 +13,7 @@ public function downloadTicketAttachment(SupportTicket $supportTicket, int $inde { $user = auth()->user(); - abort_unless($user->id === $supportTicket->user_id || $user->isAdmin(), 403); + abort_unless($user->can('view', $supportTicket), 403); $attachments = $supportTicket->attachments ?? []; @@ -30,7 +30,7 @@ public function downloadReplyAttachment(SupportTicket $supportTicket, Reply $rep { $user = auth()->user(); - abort_unless($user->id === $supportTicket->user_id || $user->isAdmin(), 403); + abort_unless($user->can('view', $supportTicket), 403); abort_unless($reply->support_ticket_id === $supportTicket->id, 404); $attachments = $reply->attachments ?? []; diff --git a/app/Models/User.php b/app/Models/User.php index 4aeb8457a..63fdeddcc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -66,6 +66,14 @@ public function getFilamentName(): string * role, whose permissions then decide which sections they can see. */ public function canAccessPanel(Panel $panel): bool + { + return $this->isStaff(); + } + + /** + * Staff are super admins plus anyone given a role in the admin panel. + */ + public function isStaff(): bool { return $this->isAdmin() || $this->roles->isNotEmpty(); } diff --git a/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php b/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php index 7bee723f6..9e66981ea 100644 --- a/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php +++ b/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php @@ -111,13 +111,13 @@
@forelse ($record->replies()->with('user')->orderBy('created_at', 'desc')->get() as $reply) @php - $isAdmin = $reply->user?->isAdmin(); + $isStaff = $reply->user?->isStaff(); $isNote = $reply->note; if ($isNote) { $bgColor = '#fefce8'; $borderColor = '#facc15'; - } elseif ($isAdmin) { + } elseif ($isStaff) { $bgColor = '#ede9fe'; $borderColor = '#c4b5fd'; } else { @@ -139,7 +139,7 @@ @if ($reply->pinned) Pinned @endif - @elseif ($isAdmin) + @elseif ($isStaff) Staff @endif @endif diff --git a/tests/Feature/AdminPluginPreviewTest.php b/tests/Feature/AdminPluginPreviewTest.php index 981253ee6..b867104de 100644 --- a/tests/Feature/AdminPluginPreviewTest.php +++ b/tests/Feature/AdminPluginPreviewTest.php @@ -7,6 +7,8 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Pennant\Feature; +use Spatie\Permission\Models\Permission; +use Spatie\Permission\Models\Role; use Tests\TestCase; class AdminPluginPreviewTest extends TestCase @@ -83,4 +85,67 @@ public function test_admin_can_view_approved_plugin_without_preview_banner(): vo ->assertStatus(200) ->assertDontSee('This plugin is not publicly visible'); } + + public function test_staff_with_plugin_view_permission_can_preview_pending_plugin(): void + { + $plugin = Plugin::factory()->pending()->create(); + + $this->actingAs($this->staffWithPermissions(['ViewAny:Plugin', 'View:Plugin'])) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertOk() + ->assertSee('This plugin is not publicly visible.'); + } + + public function test_staff_with_plugin_view_permission_can_preview_paid_pending_plugin_and_its_license(): void + { + $plugin = Plugin::factory()->pending()->paid()->create(['license_html' => '

License terms

']); + $staff = $this->staffWithPermissions(['ViewAny:Plugin', 'View:Plugin']); + + $this->actingAs($staff) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertOk(); + + $this->actingAs($staff) + ->get(route('plugins.license', $plugin->routeParams())) + ->assertOk() + ->assertSee('License terms', false); + } + + public function test_staff_without_plugin_view_permission_cannot_preview_pending_plugin(): void + { + $plugin = Plugin::factory()->pending()->create(); + + $this->actingAs($this->staffWithPermissions(['ViewAny:Lead'])) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertNotFound(); + } + + public function test_super_admin_role_can_preview_pending_plugin(): void + { + $superAdmin = User::factory()->create(); + $superAdmin->assignRole(Role::findOrCreate(config('filament-shield.super_admin.name'))); + + $plugin = Plugin::factory()->pending()->create(); + + $this->actingAs($superAdmin) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertOk(); + } + + /** + * @param list $permissions + */ + private function staffWithPermissions(array $permissions): User + { + $role = Role::findOrCreate('test_role'); + + foreach ($permissions as $permission) { + $role->givePermissionTo(Permission::findOrCreate($permission)); + } + + $user = User::factory()->create(); + $user->assignRole($role); + + return $user; + } } diff --git a/tests/Feature/Filament/AdminRolesAndPermissionsTest.php b/tests/Feature/Filament/AdminRolesAndPermissionsTest.php index 3920d19b0..7e3d3d7a9 100644 --- a/tests/Feature/Filament/AdminRolesAndPermissionsTest.php +++ b/tests/Feature/Filament/AdminRolesAndPermissionsTest.php @@ -11,10 +11,12 @@ use App\Filament\Resources\PluginResource\RelationManagers\ActivitiesRelationManager; use App\Filament\Resources\PluginResource\RelationManagers\PricesRelationManager; use App\Filament\Resources\SalesResource; +use App\Filament\Resources\SupportTicketResource\Pages\ViewSupportTicket; use App\Filament\Resources\SupportTicketResource\Widgets\TicketRepliesWidget; use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource\Pages\EditUser; use App\Filament\Widgets\StatsOverview; +use App\Models\Article; use App\Models\Plugin; use App\Models\PluginActivity; use App\Models\SupportTicket; @@ -26,6 +28,7 @@ use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; @@ -317,6 +320,79 @@ public function test_every_admin_page_and_widget_requires_a_permission(): void } } + public function test_staff_with_article_view_permission_can_preview_a_draft_article(): void + { + $draft = Article::factory()->create(['published_at' => null]); + + $this->actingAs($this->userWithRole(['ViewAny:Article', 'View:Article'])) + ->get(route('article', $draft)) + ->assertOk(); + + $this->actingAs($this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS)) + ->get(route('article', $draft)) + ->assertNotFound(); + } + + public function test_staff_with_ticket_view_permission_can_download_ticket_attachments(): void + { + Storage::fake('support-tickets', ['serve' => true]); + Storage::disk('support-tickets')->put('ticket/screenshot.png', 'image'); + + $ticket = SupportTicket::factory()->create([ + 'attachments' => [['name' => 'screenshot.png', 'path' => 'ticket/screenshot.png', 'size' => 5, 'mime_type' => 'image/png']], + ]); + + $this->actingAs($this->userWithRole(['ViewAny:SupportTicket', 'View:SupportTicket'])) + ->get(route('customer.support.tickets.attachment', [$ticket, 0])) + ->assertRedirect(); + + $this->actingAs($this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS)) + ->get(route('customer.support.tickets.attachment', [$ticket, 0])) + ->assertForbidden(); + } + + public function test_anyone_with_a_role_counts_as_staff(): void + { + $this->assertTrue($this->userWithRole(['ViewAny:SupportTicket'])->isStaff()); + $this->assertTrue($this->superAdmin()->isStaff()); + $this->assertFalse(User::factory()->create()->isStaff()); + } + + public function test_links_to_other_sections_are_hidden_when_the_role_cannot_open_them(): void + { + $ticket = SupportTicket::factory()->create(); + $userUrl = UserResource::getUrl('edit', ['record' => $ticket->user_id]); + + $this->actingAs($this->userWithRole(['ViewAny:SupportTicket', 'View:SupportTicket'])) + ->get(ViewSupportTicket::getUrl(['record' => $ticket])) + ->assertOk() + ->assertDontSee($userUrl, false); + } + + public function test_super_admin_sees_links_to_other_sections(): void + { + $ticket = SupportTicket::factory()->create(); + + $this->actingAs($this->superAdmin()) + ->get(ViewSupportTicket::getUrl(['record' => $ticket])) + ->assertOk() + ->assertSee(UserResource::getUrl('edit', ['record' => $ticket->user_id]), false); + } + + public function test_go_to_user_action_is_hidden_without_user_access(): void + { + $plugin = Plugin::factory()->pending()->free()->create(); + $goToUser = TestAction::make('viewUser')->schemaComponent('user_id'); + + Livewire::actingAs($this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS)) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionHidden($goToUser); + + Livewire::actingAs($this->superAdmin()) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionVisible($goToUser); + } + private function superAdmin(): User { $user = User::factory()->create();