diff --git a/app/Filament/Actions/RefundPluginLicenseAction.php b/app/Filament/Actions/RefundPluginLicenseAction.php index acceba7ad..8418973e9 100644 --- a/app/Filament/Actions/RefundPluginLicenseAction.php +++ b/app/Filament/Actions/RefundPluginLicenseAction.php @@ -19,6 +19,7 @@ protected function setUp(): void parent::setUp(); $this + ->authorize('refund') ->label('Refund') ->icon('heroicon-o-receipt-refund') ->color('danger') diff --git a/app/Filament/Pages/Companies.php b/app/Filament/Pages/Companies.php index 5979001e2..a06278a87 100644 --- a/app/Filament/Pages/Companies.php +++ b/app/Filament/Pages/Companies.php @@ -4,6 +4,7 @@ use App\Filament\Resources\UserResource; use App\Services\CompanyAggregator; +use BezhanSalleh\FilamentShield\Traits\HasPageShield; use Filament\Actions\Action; use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedTable; @@ -18,7 +19,7 @@ class Companies extends Page implements HasTable { - use InteractsWithTable; + use HasPageShield, InteractsWithTable; protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-building-office-2'; diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php index 181f0f065..20d5811ce 100644 --- a/app/Filament/Pages/Dashboard.php +++ b/app/Filament/Pages/Dashboard.php @@ -7,12 +7,33 @@ use App\Filament\Widgets\StatsOverview; use App\Filament\Widgets\SubscriberIncomeChart; use App\Filament\Widgets\UsersChart; +use Filament\Facades\Filament; use Filament\Pages\Dashboard as BaseDashboard; class Dashboard extends BaseDashboard { protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-home'; + /** + * Staff whose role can't see any dashboard widget are sent straight to + * the first section they can open instead of an empty page. + */ + public function mount(): void + { + if (static::hasVisibleWidgets()) { + return; + } + + if ($url = static::firstAccessibleNavigationUrl()) { + $this->redirect($url); + } + } + + public static function shouldRegisterNavigation(): bool + { + return parent::shouldRegisterNavigation() && static::hasVisibleWidgets(); + } + public function getHeaderWidgets(): array { return [ @@ -29,4 +50,25 @@ public function getWidgets(): array PluginRevenueChart::class, ]; } + + protected static function hasVisibleWidgets(): bool + { + $dashboard = new static; + + return collect([...$dashboard->getHeaderWidgets(), ...$dashboard->getWidgets()]) + ->contains(fn (string $widget): bool => $widget::canView()); + } + + protected static function firstAccessibleNavigationUrl(): ?string + { + foreach (Filament::getNavigation() as $group) { + foreach ($group->getItems() as $item) { + if ($item->isVisible() && filled($item->getUrl())) { + return $item->getUrl(); + } + } + } + + return null; + } } diff --git a/app/Filament/Resources/Concerns/AuthorizesThroughOwnerRecord.php b/app/Filament/Resources/Concerns/AuthorizesThroughOwnerRecord.php new file mode 100644 index 000000000..2bb3ce94b --- /dev/null +++ b/app/Filament/Resources/Concerns/AuthorizesThroughOwnerRecord.php @@ -0,0 +1,41 @@ +user())->allows('view', $ownerRecord); + } + + public function getAuthorizationResponse(string $action, ?Model $record = null): Response + { + if (static::shouldSkipAuthorization()) { + return Response::allow(); + } + + $ability = in_array($action, ['viewAny', 'view'], true) ? 'view' : static::ownerUpdateAbility(); + + return Gate::forUser(Filament::auth()->user())->inspect($ability, $this->getOwnerRecord()); + } + + /** + * The owner ability needed to change these records. + */ + protected static function ownerUpdateAbility(): string + { + return 'update'; + } +} diff --git a/app/Filament/Resources/PluginBundleResource/RelationManagers/PluginsRelationManager.php b/app/Filament/Resources/PluginBundleResource/RelationManagers/PluginsRelationManager.php index 8239b277d..6993659b9 100644 --- a/app/Filament/Resources/PluginBundleResource/RelationManagers/PluginsRelationManager.php +++ b/app/Filament/Resources/PluginBundleResource/RelationManagers/PluginsRelationManager.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\PluginBundleResource\RelationManagers; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use App\Models\Plugin; use App\Models\PluginBundle; use Filament\Actions; @@ -13,6 +14,8 @@ class PluginsRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'plugins'; protected static ?string $recordTitleAttribute = 'name'; diff --git a/app/Filament/Resources/PluginBundleResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/PluginBundleResource/RelationManagers/PricesRelationManager.php index a248c95ba..acfac4e37 100644 --- a/app/Filament/Resources/PluginBundleResource/RelationManagers/PricesRelationManager.php +++ b/app/Filament/Resources/PluginBundleResource/RelationManagers/PricesRelationManager.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\PluginBundleResource\RelationManagers; use App\Enums\PriceTier; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use Filament\Actions; use Filament\Forms; use Filament\Resources\RelationManagers\RelationManager; @@ -12,6 +13,8 @@ class PricesRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'prices'; protected static ?string $title = 'Pricing'; diff --git a/app/Filament/Resources/PluginIdeaResource/RelationManagers/MissedSearchesRelationManager.php b/app/Filament/Resources/PluginIdeaResource/RelationManagers/MissedSearchesRelationManager.php index adde8656c..39924e7dc 100644 --- a/app/Filament/Resources/PluginIdeaResource/RelationManagers/MissedSearchesRelationManager.php +++ b/app/Filament/Resources/PluginIdeaResource/RelationManagers/MissedSearchesRelationManager.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\PluginIdeaResource\RelationManagers; use App\Enums\PluginType; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use App\Models\MissedPluginSearch; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; @@ -12,6 +13,8 @@ final class MissedSearchesRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'missedSearches'; protected static ?string $title = 'Searches'; diff --git a/app/Filament/Resources/PluginPayoutResource/RelationManagers/AttemptsRelationManager.php b/app/Filament/Resources/PluginPayoutResource/RelationManagers/AttemptsRelationManager.php index 87de67827..2533830d3 100644 --- a/app/Filament/Resources/PluginPayoutResource/RelationManagers/AttemptsRelationManager.php +++ b/app/Filament/Resources/PluginPayoutResource/RelationManagers/AttemptsRelationManager.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\PluginPayoutResource\RelationManagers; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use App\Models\PluginPayoutAttempt; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; @@ -9,6 +10,8 @@ class AttemptsRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'attempts'; protected static ?string $title = 'Attempt History'; diff --git a/app/Filament/Resources/PluginReportResource.php b/app/Filament/Resources/PluginReportResource.php index 7a3c7bd63..07dd14d7b 100644 --- a/app/Filament/Resources/PluginReportResource.php +++ b/app/Filament/Resources/PluginReportResource.php @@ -63,6 +63,7 @@ private static function resolutionActions(): array { return [ Actions\Action::make('resolve') + ->authorize('update') ->label('Mark Resolved') ->icon('heroicon-o-check-circle') ->color('success') @@ -83,6 +84,7 @@ private static function resolutionActions(): array }), Actions\Action::make('dismiss') + ->authorize('update') ->label('Dismiss') ->icon('heroicon-o-x-circle') ->color('gray') diff --git a/app/Filament/Resources/PluginReportResource/Pages/ViewPluginReport.php b/app/Filament/Resources/PluginReportResource/Pages/ViewPluginReport.php index 17a596bca..0178d8a8c 100644 --- a/app/Filament/Resources/PluginReportResource/Pages/ViewPluginReport.php +++ b/app/Filament/Resources/PluginReportResource/Pages/ViewPluginReport.php @@ -20,6 +20,7 @@ protected function getHeaderActions(): array { return [ Actions\Action::make('resolve') + ->authorize('update') ->label('Mark Resolved') ->icon('heroicon-o-check-circle') ->color('success') @@ -44,6 +45,7 @@ protected function getHeaderActions(): array }), Actions\Action::make('dismiss') + ->authorize('update') ->label('Dismiss') ->icon('heroicon-o-x-circle') ->color('gray') diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index 5a34650e9..2de1aba46 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -28,6 +28,7 @@ use Filament\Tables\Contracts\HasTable; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\HtmlString; use Throwable; @@ -69,12 +70,14 @@ public static function form(Schema $schema): Schema ->label('Description'), Forms\Components\Select::make('type') - ->options(PluginType::class), + ->options(PluginType::class) + ->disabled(fn (?Plugin $record): bool => static::cannotChangePricing($record)), Forms\Components\Select::make('tier') ->options(PluginTier::class) ->placeholder('No tier') - ->helperText('Set pricing tier for paid plugins'), + ->helperText('Set pricing tier for paid plugins') + ->disabled(fn (?Plugin $record): bool => static::cannotChangePricing($record)), Forms\Components\Placeholder::make('name') ->label('Composer Package Name') @@ -262,6 +265,7 @@ public static function form(Schema $schema): Schema ]) ->headerActions([ Action::make('emailReviewChecks') + ->authorize('messageDeveloper') ->label('Email Developer') ->icon('heroicon-o-envelope') ->color('warning') @@ -502,6 +506,7 @@ public static function table(Table $table): Table ->actions([ Actions\ActionGroup::make([ Action::make('resync') + ->authorize('resync') ->label('Re-sync from GitHub') ->icon('heroicon-o-arrow-path') ->color('primary') @@ -520,6 +525,7 @@ public static function table(Table $table): Table }), Action::make('grantToUser') + ->authorize('grantToUser') ->label('Grant to User') ->icon('heroicon-o-gift') ->color('success') @@ -578,6 +584,7 @@ public static function table(Table $table): Table ->modalSubmitActionLabel('Grant'), Action::make('runReviewChecks') + ->authorize('runReviewChecks') ->label('Run Review Checks') ->icon('heroicon-o-clipboard-document-check') ->color('primary') @@ -652,6 +659,15 @@ public static function table(Table $table): Table ); } + /** + * Type and tier decide what a plugin costs, so they follow the + * "Convert to paid" permission rather than plain update access. + */ + protected static function cannotChangePricing(?Plugin $record): bool + { + return $record !== null && Gate::denies('convertToPaid', $record); + } + public static function getRelations(): array { return [ diff --git a/app/Filament/Resources/PluginResource/Pages/EditPlugin.php b/app/Filament/Resources/PluginResource/Pages/EditPlugin.php index 40cf339e9..052ee9456 100644 --- a/app/Filament/Resources/PluginResource/Pages/EditPlugin.php +++ b/app/Filament/Resources/PluginResource/Pages/EditPlugin.php @@ -49,6 +49,7 @@ protected function getHeaderActions(): array { return [ Actions\Action::make('approve') + ->authorize('approve') ->icon('heroicon-o-check') ->color('success') ->visible(fn () => $this->record->isPending()) @@ -61,6 +62,7 @@ protected function getHeaderActions(): array : "Are you sure you want to approve '{$this->record->name}'?"), Actions\Action::make('reject') + ->authorize('reject') ->icon('heroicon-o-x-mark') ->color('danger') ->visible(fn () => $this->record->isPending() || $this->record->isApproved()) @@ -76,6 +78,7 @@ protected function getHeaderActions(): array ->modalDescription(fn () => "Are you sure you want to reject '{$this->record->name}'?"), Actions\Action::make('messageDeveloper') + ->authorize('messageDeveloper') ->label('Message Developer') ->icon('heroicon-o-chat-bubble-left-right') ->color('info') @@ -111,6 +114,7 @@ protected function getHeaderActions(): array Actions\ActionGroup::make([ Actions\Action::make('convertToPaid') + ->authorize('convertToPaid') ->label('Convert to Paid') ->icon('heroicon-o-currency-dollar') ->color('success') @@ -139,6 +143,7 @@ protected function getHeaderActions(): array ->modalSubmitActionLabel('Convert & Ingest'), Actions\Action::make('syncToSatis') + ->authorize('syncToSatis') ->label(fn () => $this->record->isSatisSynced() ? 'Re-sync to Satis' : 'Sync to Satis') ->icon('heroicon-o-arrow-path') ->color('warning') @@ -159,6 +164,7 @@ protected function getHeaderActions(): array }), Actions\Action::make('grantToUser') + ->authorize('grantToUser') ->label('Grant to User') ->icon('heroicon-o-gift') ->color('success') @@ -217,6 +223,7 @@ protected function getHeaderActions(): array ->modalSubmitActionLabel('Grant'), Actions\Action::make('runReviewChecks') + ->authorize('runReviewChecks') ->label('Run Review Checks') ->icon('heroicon-o-clipboard-document-check') ->color('primary') @@ -277,6 +284,7 @@ protected function getHeaderActions(): array }), Actions\Action::make('resync') + ->authorize('resync') ->label('Re-sync from GitHub') ->icon('heroicon-o-arrow-path') ->color('primary') diff --git a/app/Filament/Resources/PluginResource/Pages/ViewPlugin.php b/app/Filament/Resources/PluginResource/Pages/ViewPlugin.php index 600f29e6b..e8bb873cf 100644 --- a/app/Filament/Resources/PluginResource/Pages/ViewPlugin.php +++ b/app/Filament/Resources/PluginResource/Pages/ViewPlugin.php @@ -23,6 +23,7 @@ protected function getHeaderActions(): array ->visible(fn () => $this->record->isApproved() || $this->record->isPending()), Actions\Action::make('approve') + ->authorize('approve') ->icon('heroicon-o-check') ->color('success') ->visible(fn () => $this->record->isPending()) @@ -32,6 +33,7 @@ protected function getHeaderActions(): array ->modalDescription(fn () => "Are you sure you want to approve '{$this->record->name}'?"), Actions\Action::make('reject') + ->authorize('reject') ->icon('heroicon-o-x-mark') ->color('danger') ->visible(fn () => $this->record->isPending() || $this->record->isApproved()) diff --git a/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php index 9eeb8db7f..1587ab827 100644 --- a/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php +++ b/app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php @@ -3,12 +3,15 @@ namespace App\Filament\Resources\PluginResource\RelationManagers; use App\Enums\PluginActivityType; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; class ActivitiesRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'activities'; protected static ?string $title = 'Activity History'; diff --git a/app/Filament/Resources/PluginResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/PricesRelationManager.php index eefd3b27e..cbc8068d8 100644 --- a/app/Filament/Resources/PluginResource/RelationManagers/PricesRelationManager.php +++ b/app/Filament/Resources/PluginResource/RelationManagers/PricesRelationManager.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\PluginResource\RelationManagers; use App\Enums\PriceTier; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use Filament\Actions; use Filament\Forms; use Filament\Resources\RelationManagers\RelationManager; @@ -12,6 +13,13 @@ class PricesRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + + protected static function ownerUpdateAbility(): string + { + return 'convertToPaid'; + } + protected static string $relationship = 'prices'; protected static ?string $title = 'Pricing'; diff --git a/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php index 05ce0a124..82170332a 100644 --- a/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php +++ b/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources\PluginResource\RelationManagers; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use Filament\Actions; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; @@ -11,6 +12,8 @@ final class RatingsRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'ratings'; protected static ?string $title = 'Ratings'; diff --git a/app/Filament/Resources/ProductResource/RelationManagers/LicensesRelationManager.php b/app/Filament/Resources/ProductResource/RelationManagers/LicensesRelationManager.php index ba66c92d3..9a8136e0c 100644 --- a/app/Filament/Resources/ProductResource/RelationManagers/LicensesRelationManager.php +++ b/app/Filament/Resources/ProductResource/RelationManagers/LicensesRelationManager.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\ProductResource\RelationManagers; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use Filament\Actions; use Filament\Forms; use Filament\Resources\RelationManagers\RelationManager; @@ -11,6 +12,8 @@ class LicensesRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'licenses'; protected static ?string $title = 'Licenses'; diff --git a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php index 1a5228190..8ff07457d 100644 --- a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php +++ b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\ProductResource\RelationManagers; use App\Enums\PriceTier; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use App\Models\ProductPrice; use Filament\Actions; use Filament\Forms; @@ -16,6 +17,8 @@ class PricesRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'prices'; protected static ?string $title = 'Pricing'; diff --git a/app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php b/app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php index a28f490af..36733cc84 100644 --- a/app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php +++ b/app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php @@ -20,6 +20,7 @@ protected function getHeaderActions(): array Impersonate::make()->impersonateRecord(fn () => $this->getRecord()->user), Actions\Action::make('updateStatus') + ->authorize('update') ->label('Update Status') ->icon('heroicon-o-arrow-path') ->form([ diff --git a/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php b/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php index f7e33017b..e614f4639 100644 --- a/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php +++ b/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\SupportTicketResource\RelationManagers; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use App\Models\SupportTicket\Reply; use App\Notifications\SupportTicketReplied; use Filament\Forms; @@ -12,6 +13,8 @@ class RepliesRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'replies'; protected static ?string $title = 'Replies'; diff --git a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php index abde9dea3..a84464fc1 100644 --- a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php +++ b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php @@ -31,6 +31,8 @@ protected function getListeners(): array public function sendReply(): void { + $this->authorize('update', $this->record); + $this->validate([ 'newMessage' => ['required', 'string', 'max:5000'], 'replyAttachments' => ['array', 'max:5'], diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index f451fdc99..fe27ae55d 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -14,6 +14,8 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Support\HtmlString; +use Illuminate\Support\Str; +use Spatie\Permission\Models\Role; use STS\FilamentImpersonate\Actions\Impersonate; class UserResource extends Resource @@ -45,6 +47,16 @@ public static function form(Schema $schema): Schema ->hidden(fn (string $context): bool => $context === 'edit') ->maxLength(255), ]), + Schemas\Components\Section::make('Admin Access') + ->description('Roles decide which parts of this admin panel the user can open. Customers need none.') + ->inlineLabel() + ->columns(1) + ->visible(fn (): bool => auth()->user()?->isAdmin() ?? false) + ->schema([ + Forms\Components\CheckboxList::make('roles') + ->relationship('roles', 'name') + ->getOptionLabelFromRecordUsing(fn (Role $record): string => Str::headline($record->name)), + ]), Schemas\Components\Section::make('Billing Information') ->inlineLabel() ->columns(1) diff --git a/app/Filament/Resources/UserResource/RelationManagers/ProductLicensesRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/ProductLicensesRelationManager.php index e5ca20d87..425cd36ad 100644 --- a/app/Filament/Resources/UserResource/RelationManagers/ProductLicensesRelationManager.php +++ b/app/Filament/Resources/UserResource/RelationManagers/ProductLicensesRelationManager.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\UserResource\RelationManagers; +use App\Filament\Resources\Concerns\AuthorizesThroughOwnerRecord; use Filament\Actions; use Filament\Forms; use Filament\Resources\RelationManagers\RelationManager; @@ -11,6 +12,8 @@ class ProductLicensesRelationManager extends RelationManager { + use AuthorizesThroughOwnerRecord; + protected static string $relationship = 'productLicenses'; protected static ?string $title = 'Products'; diff --git a/app/Filament/Widgets/LicenseDistributionChart.php b/app/Filament/Widgets/LicenseDistributionChart.php index 071215554..40c89fd49 100644 --- a/app/Filament/Widgets/LicenseDistributionChart.php +++ b/app/Filament/Widgets/LicenseDistributionChart.php @@ -3,11 +3,14 @@ namespace App\Filament\Widgets; use App\Models\License; +use BezhanSalleh\FilamentShield\Traits\HasWidgetShield; use Filament\Widgets\ChartWidget; use Illuminate\Support\Facades\DB; class LicenseDistributionChart extends ChartWidget { + use HasWidgetShield; + protected ?string $heading = 'License Types'; protected static ?int $sort = 4; diff --git a/app/Filament/Widgets/PluginRevenueChart.php b/app/Filament/Widgets/PluginRevenueChart.php index 0464086b3..3835d2a9e 100644 --- a/app/Filament/Widgets/PluginRevenueChart.php +++ b/app/Filament/Widgets/PluginRevenueChart.php @@ -3,11 +3,14 @@ namespace App\Filament\Widgets; use App\Models\PluginLicense; +use BezhanSalleh\FilamentShield\Traits\HasWidgetShield; use Filament\Widgets\ChartWidget; use Illuminate\Support\Facades\DB; class PluginRevenueChart extends ChartWidget { + use HasWidgetShield; + protected ?string $heading = 'Revenue by Plugin'; protected static ?int $sort = 5; diff --git a/app/Filament/Widgets/StatsOverview.php b/app/Filament/Widgets/StatsOverview.php index d09c71a47..2b9e22119 100644 --- a/app/Filament/Widgets/StatsOverview.php +++ b/app/Filament/Widgets/StatsOverview.php @@ -4,12 +4,15 @@ use App\Models\License; use App\Models\User; +use BezhanSalleh\FilamentShield\Traits\HasWidgetShield; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; use Laravel\Cashier\Subscription; class StatsOverview extends BaseWidget { + use HasWidgetShield; + protected function getStats(): array { return [ diff --git a/app/Filament/Widgets/SubscriberIncomeChart.php b/app/Filament/Widgets/SubscriberIncomeChart.php index ca8075b54..35397f082 100644 --- a/app/Filament/Widgets/SubscriberIncomeChart.php +++ b/app/Filament/Widgets/SubscriberIncomeChart.php @@ -2,6 +2,7 @@ namespace App\Filament\Widgets; +use BezhanSalleh\FilamentShield\Traits\HasWidgetShield; use Carbon\Carbon; use Filament\Support\RawJs; use Filament\Widgets\ChartWidget; @@ -9,6 +10,8 @@ class SubscriberIncomeChart extends ChartWidget { + use HasWidgetShield; + protected ?string $heading = 'Subscriber Income'; protected static ?int $sort = 3; diff --git a/app/Filament/Widgets/UsersChart.php b/app/Filament/Widgets/UsersChart.php index e57e548ae..6f98bf20d 100644 --- a/app/Filament/Widgets/UsersChart.php +++ b/app/Filament/Widgets/UsersChart.php @@ -3,11 +3,14 @@ namespace App\Filament\Widgets; use App\Models\User; +use BezhanSalleh\FilamentShield\Traits\HasWidgetShield; use Carbon\Carbon; use Filament\Widgets\ChartWidget; class UsersChart extends ChartWidget { + use HasWidgetShield; + protected ?string $heading = 'User Growth'; protected static ?int $sort = 2; diff --git a/app/Models/User.php b/app/Models/User.php index ba4679feb..d73fc3811 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -22,10 +22,11 @@ use Laravel\Passport\Contracts\ScopeAuthorizable; use Laravel\Sanctum\Contracts\HasAbilities; use Laravel\Sanctum\HasApiTokens; +use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable implements FilamentUser, HasName, MustVerifyEmail { - use Billable, HasApiTokens, HasFactory, Notifiable; + use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable; /** @var HasAbilities|ScopeAuthorizable|null */ protected $accessToken; @@ -60,14 +61,31 @@ public function getFilamentName(): string return $this->attributes['display_name'] ?? $this->name ?? $this->email; } + /** + * Super admins can always reach the panel. Anyone else needs at least one + * role, whose permissions then decide which sections they can see. + */ public function canAccessPanel(Panel $panel): bool + { + return $this->isAdmin() || $this->roles->isNotEmpty(); + } + + /** + * Only super admins can impersonate, so staff with a narrower role can't + * borrow anyone else's access. + */ + public function canImpersonate(): bool { return $this->isAdmin(); } + /** + * Super admins: anyone listed in FILAMENT_USERS or holding the super admin role. + */ public function isAdmin(): bool { - return in_array($this->email, config('filament.users'), true); + return in_array($this->email, config('filament.users'), true) + || $this->hasRole(config('filament-shield.super_admin.name')); } /** diff --git a/app/Policies/ArticlePolicy.php b/app/Policies/ArticlePolicy.php new file mode 100644 index 000000000..681365a98 --- /dev/null +++ b/app/Policies/ArticlePolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:Article'); + } + + public function view(AuthUser $authUser, Article $article): bool + { + return $authUser->can('View:Article'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:Article'); + } + + public function update(AuthUser $authUser, Article $article): bool + { + return $authUser->can('Update:Article'); + } + + public function delete(AuthUser $authUser, Article $article): bool + { + return $authUser->can('Delete:Article'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:Article'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:Article'); + } +} diff --git a/app/Policies/CourseLessonPolicy.php b/app/Policies/CourseLessonPolicy.php new file mode 100644 index 000000000..532155593 --- /dev/null +++ b/app/Policies/CourseLessonPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:CourseLesson'); + } + + public function view(AuthUser $authUser, CourseLesson $courseLesson): bool + { + return $authUser->can('View:CourseLesson'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:CourseLesson'); + } + + public function update(AuthUser $authUser, CourseLesson $courseLesson): bool + { + return $authUser->can('Update:CourseLesson'); + } + + public function delete(AuthUser $authUser, CourseLesson $courseLesson): bool + { + return $authUser->can('Delete:CourseLesson'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:CourseLesson'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:CourseLesson'); + } +} diff --git a/app/Policies/CourseModulePolicy.php b/app/Policies/CourseModulePolicy.php new file mode 100644 index 000000000..fb4fdb88b --- /dev/null +++ b/app/Policies/CourseModulePolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:CourseModule'); + } + + public function view(AuthUser $authUser, CourseModule $courseModule): bool + { + return $authUser->can('View:CourseModule'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:CourseModule'); + } + + public function update(AuthUser $authUser, CourseModule $courseModule): bool + { + return $authUser->can('Update:CourseModule'); + } + + public function delete(AuthUser $authUser, CourseModule $courseModule): bool + { + return $authUser->can('Delete:CourseModule'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:CourseModule'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:CourseModule'); + } +} diff --git a/app/Policies/CoursePolicy.php b/app/Policies/CoursePolicy.php new file mode 100644 index 000000000..3a3b73bd5 --- /dev/null +++ b/app/Policies/CoursePolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:Course'); + } + + public function view(AuthUser $authUser, Course $course): bool + { + return $authUser->can('View:Course'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:Course'); + } + + public function update(AuthUser $authUser, Course $course): bool + { + return $authUser->can('Update:Course'); + } + + public function delete(AuthUser $authUser, Course $course): bool + { + return $authUser->can('Delete:Course'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:Course'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:Course'); + } +} diff --git a/app/Policies/LeadPolicy.php b/app/Policies/LeadPolicy.php new file mode 100644 index 000000000..fdcb7150f --- /dev/null +++ b/app/Policies/LeadPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:Lead'); + } + + public function view(AuthUser $authUser, Lead $lead): bool + { + return $authUser->can('View:Lead'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:Lead'); + } + + public function update(AuthUser $authUser, Lead $lead): bool + { + return $authUser->can('Update:Lead'); + } + + public function delete(AuthUser $authUser, Lead $lead): bool + { + return $authUser->can('Delete:Lead'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:Lead'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:Lead'); + } +} diff --git a/app/Policies/LicensePolicy.php b/app/Policies/LicensePolicy.php new file mode 100644 index 000000000..399f0731f --- /dev/null +++ b/app/Policies/LicensePolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:License'); + } + + public function view(AuthUser $authUser, License $license): bool + { + return $authUser->can('View:License'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:License'); + } + + public function update(AuthUser $authUser, License $license): bool + { + return $authUser->can('Update:License'); + } + + public function delete(AuthUser $authUser, License $license): bool + { + return $authUser->can('Delete:License'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:License'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:License'); + } +} diff --git a/app/Policies/OpenCollectiveDonationPolicy.php b/app/Policies/OpenCollectiveDonationPolicy.php new file mode 100644 index 000000000..0c6cd31e3 --- /dev/null +++ b/app/Policies/OpenCollectiveDonationPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:OpenCollectiveDonation'); + } + + public function view(AuthUser $authUser, OpenCollectiveDonation $openCollectiveDonation): bool + { + return $authUser->can('View:OpenCollectiveDonation'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:OpenCollectiveDonation'); + } + + public function update(AuthUser $authUser, OpenCollectiveDonation $openCollectiveDonation): bool + { + return $authUser->can('Update:OpenCollectiveDonation'); + } + + public function delete(AuthUser $authUser, OpenCollectiveDonation $openCollectiveDonation): bool + { + return $authUser->can('Delete:OpenCollectiveDonation'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:OpenCollectiveDonation'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:OpenCollectiveDonation'); + } +} diff --git a/app/Policies/PluginBundlePolicy.php b/app/Policies/PluginBundlePolicy.php new file mode 100644 index 000000000..94b50ad0c --- /dev/null +++ b/app/Policies/PluginBundlePolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:PluginBundle'); + } + + public function view(AuthUser $authUser, PluginBundle $pluginBundle): bool + { + return $authUser->can('View:PluginBundle'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:PluginBundle'); + } + + public function update(AuthUser $authUser, PluginBundle $pluginBundle): bool + { + return $authUser->can('Update:PluginBundle'); + } + + public function delete(AuthUser $authUser, PluginBundle $pluginBundle): bool + { + return $authUser->can('Delete:PluginBundle'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:PluginBundle'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:PluginBundle'); + } +} diff --git a/app/Policies/PluginIdeaPolicy.php b/app/Policies/PluginIdeaPolicy.php new file mode 100644 index 000000000..e9a33803a --- /dev/null +++ b/app/Policies/PluginIdeaPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:PluginIdea'); + } + + public function view(AuthUser $authUser, PluginIdea $pluginIdea): bool + { + return $authUser->can('View:PluginIdea'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:PluginIdea'); + } + + public function update(AuthUser $authUser, PluginIdea $pluginIdea): bool + { + return $authUser->can('Update:PluginIdea'); + } + + public function delete(AuthUser $authUser, PluginIdea $pluginIdea): bool + { + return $authUser->can('Delete:PluginIdea'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:PluginIdea'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:PluginIdea'); + } +} diff --git a/app/Policies/PluginLicensePolicy.php b/app/Policies/PluginLicensePolicy.php new file mode 100644 index 000000000..87bcb6dac --- /dev/null +++ b/app/Policies/PluginLicensePolicy.php @@ -0,0 +1,54 @@ +can('ViewAny:PluginLicense'); + } + + public function view(AuthUser $authUser, PluginLicense $pluginLicense): bool + { + return $authUser->can('View:PluginLicense'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:PluginLicense'); + } + + public function update(AuthUser $authUser, PluginLicense $pluginLicense): bool + { + return $authUser->can('Update:PluginLicense'); + } + + public function delete(AuthUser $authUser, PluginLicense $pluginLicense): bool + { + return $authUser->can('Delete:PluginLicense'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:PluginLicense'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:PluginLicense'); + } + + public function refund(AuthUser $authUser, PluginLicense $pluginLicense): bool + { + return $authUser->can('Refund:PluginLicense'); + } +} diff --git a/app/Policies/PluginPayoutPolicy.php b/app/Policies/PluginPayoutPolicy.php new file mode 100644 index 000000000..36e032260 --- /dev/null +++ b/app/Policies/PluginPayoutPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:PluginPayout'); + } + + public function view(AuthUser $authUser, PluginPayout $pluginPayout): bool + { + return $authUser->can('View:PluginPayout'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:PluginPayout'); + } + + public function update(AuthUser $authUser, PluginPayout $pluginPayout): bool + { + return $authUser->can('Update:PluginPayout'); + } + + public function delete(AuthUser $authUser, PluginPayout $pluginPayout): bool + { + return $authUser->can('Delete:PluginPayout'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:PluginPayout'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:PluginPayout'); + } +} diff --git a/app/Policies/PluginPolicy.php b/app/Policies/PluginPolicy.php new file mode 100644 index 000000000..e50b667f3 --- /dev/null +++ b/app/Policies/PluginPolicy.php @@ -0,0 +1,89 @@ +can('ViewAny:Plugin'); + } + + public function view(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('View:Plugin'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:Plugin'); + } + + public function update(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('Update:Plugin'); + } + + public function delete(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('Delete:Plugin'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:Plugin'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:Plugin'); + } + + public function approve(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('Approve:Plugin'); + } + + public function reject(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('Reject:Plugin'); + } + + public function messageDeveloper(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('MessageDeveloper:Plugin'); + } + + public function runReviewChecks(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('RunReviewChecks:Plugin'); + } + + public function convertToPaid(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('ConvertToPaid:Plugin'); + } + + public function grantToUser(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('GrantToUser:Plugin'); + } + + public function syncToSatis(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('SyncToSatis:Plugin'); + } + + public function resync(AuthUser $authUser, Plugin $plugin): bool + { + return $authUser->can('Resync:Plugin'); + } +} diff --git a/app/Policies/PluginReportPolicy.php b/app/Policies/PluginReportPolicy.php index e197a4157..596fbe2c0 100644 --- a/app/Policies/PluginReportPolicy.php +++ b/app/Policies/PluginReportPolicy.php @@ -13,16 +13,21 @@ final class PluginReportPolicy { public function viewAny(User $user): bool { - return $user->isAdmin(); + return $user->can('ViewAny:PluginReport'); } public function view(User $user, PluginReport $pluginReport): Response { - return $user->isAdmin() + return $user->can('View:PluginReport') ? Response::allow() : Response::denyAsNotFound('Report not found.'); } + public function update(User $user, PluginReport $pluginReport): bool + { + return $user->can('Update:PluginReport'); + } + public function create(User $user, Plugin $plugin): bool { return $user->canReportPlugin($plugin); diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 000000000..c3b72950a --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:Product'); + } + + public function view(AuthUser $authUser, Product $product): bool + { + return $authUser->can('View:Product'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:Product'); + } + + public function update(AuthUser $authUser, Product $product): bool + { + return $authUser->can('Update:Product'); + } + + public function delete(AuthUser $authUser, Product $product): bool + { + return $authUser->can('Delete:Product'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:Product'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:Product'); + } +} diff --git a/app/Policies/RolePolicy.php b/app/Policies/RolePolicy.php new file mode 100644 index 000000000..9dd28d9a8 --- /dev/null +++ b/app/Policies/RolePolicy.php @@ -0,0 +1,51 @@ +isAdmin(); + } + + public function view(User $authUser, Role $role): bool + { + return $authUser->isAdmin(); + } + + public function create(User $authUser): bool + { + return $authUser->isAdmin(); + } + + public function update(User $authUser, Role $role): bool + { + return $authUser->isAdmin() && ! $this->isSuperAdminRole($role); + } + + public function delete(User $authUser, Role $role): bool + { + return $authUser->isAdmin() && ! $this->isSuperAdminRole($role); + } + + public function deleteAny(User $authUser): bool + { + return false; + } + + protected function isSuperAdminRole(Role $role): bool + { + return $role->name === config('filament-shield.super_admin.name'); + } +} diff --git a/app/Policies/SalePolicy.php b/app/Policies/SalePolicy.php new file mode 100644 index 000000000..2c8937e6a --- /dev/null +++ b/app/Policies/SalePolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:Sale'); + } + + public function view(AuthUser $authUser, Sale $sale): bool + { + return $authUser->can('View:Sale'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:Sale'); + } + + public function update(AuthUser $authUser, Sale $sale): bool + { + return $authUser->can('Update:Sale'); + } + + public function delete(AuthUser $authUser, Sale $sale): bool + { + return $authUser->can('Delete:Sale'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:Sale'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:Sale'); + } +} diff --git a/app/Policies/ShowcasePolicy.php b/app/Policies/ShowcasePolicy.php new file mode 100644 index 000000000..75fa8d389 --- /dev/null +++ b/app/Policies/ShowcasePolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:Showcase'); + } + + public function view(AuthUser $authUser, Showcase $showcase): bool + { + return $authUser->can('View:Showcase'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:Showcase'); + } + + public function update(AuthUser $authUser, Showcase $showcase): bool + { + return $authUser->can('Update:Showcase'); + } + + public function delete(AuthUser $authUser, Showcase $showcase): bool + { + return $authUser->can('Delete:Showcase'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:Showcase'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:Showcase'); + } +} diff --git a/app/Policies/SubscriptionItemPolicy.php b/app/Policies/SubscriptionItemPolicy.php new file mode 100644 index 000000000..b8024336f --- /dev/null +++ b/app/Policies/SubscriptionItemPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:SubscriptionItem'); + } + + public function view(AuthUser $authUser, SubscriptionItem $subscriptionItem): bool + { + return $authUser->can('View:SubscriptionItem'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:SubscriptionItem'); + } + + public function update(AuthUser $authUser, SubscriptionItem $subscriptionItem): bool + { + return $authUser->can('Update:SubscriptionItem'); + } + + public function delete(AuthUser $authUser, SubscriptionItem $subscriptionItem): bool + { + return $authUser->can('Delete:SubscriptionItem'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:SubscriptionItem'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:SubscriptionItem'); + } +} diff --git a/app/Policies/SubscriptionPolicy.php b/app/Policies/SubscriptionPolicy.php new file mode 100644 index 000000000..aeefd8663 --- /dev/null +++ b/app/Policies/SubscriptionPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:Subscription'); + } + + public function view(AuthUser $authUser, Subscription $subscription): bool + { + return $authUser->can('View:Subscription'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:Subscription'); + } + + public function update(AuthUser $authUser, Subscription $subscription): bool + { + return $authUser->can('Update:Subscription'); + } + + public function delete(AuthUser $authUser, Subscription $subscription): bool + { + return $authUser->can('Delete:Subscription'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:Subscription'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:Subscription'); + } +} diff --git a/app/Policies/SupportTicketPolicy.php b/app/Policies/SupportTicketPolicy.php index f954c10e3..8d158f4bb 100644 --- a/app/Policies/SupportTicketPolicy.php +++ b/app/Policies/SupportTicketPolicy.php @@ -31,7 +31,7 @@ public function reopenTicket(User $user, SupportTicket $supportTicket): bool */ public function viewAny(User $user): bool { - return $user->isAdmin(); + return $user->can('ViewAny:SupportTicket'); } /** @@ -39,7 +39,7 @@ public function viewAny(User $user): bool */ public function view(User $user, SupportTicket $supportTicket): Response { - if ($user->isAdmin()) { + if ($user->can('View:SupportTicket')) { return Response::allow(); } @@ -61,7 +61,7 @@ public function create(User $user): bool */ public function update(User $user, SupportTicket $supportTicket): bool { - return $user->id === $supportTicket->user_id; + return $user->id === $supportTicket->user_id || $user->can('Update:SupportTicket'); } /** @@ -73,6 +73,14 @@ public function delete(User $user, SupportTicket $supportTicket): bool return false; } + /** + * Determine whether the user can bulk delete models from the admin panel. + */ + public function deleteAny(User $user): bool + { + return $user->can('DeleteAny:SupportTicket'); + } + /** * Determine whether the user can restore the model. */ diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 000000000..2de5df8ac --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,57 @@ +can('ViewAny:User'); + } + + public function view(User $authUser, User $user): bool + { + return $authUser->can('View:User'); + } + + public function create(User $authUser): bool + { + return $authUser->can('Create:User'); + } + + public function update(User $authUser, User $user): bool + { + return $authUser->can('Update:User') && $this->canManage($authUser, $user); + } + + public function delete(User $authUser, User $user): bool + { + return $authUser->can('Delete:User') && $this->canManage($authUser, $user); + } + + public function deleteAny(User $authUser): bool + { + return $authUser->can('DeleteAny:User'); + } + + public function reorder(User $authUser): bool + { + return $authUser->can('Reorder:User'); + } + + protected function canManage(User $authUser, User $user): bool + { + return $authUser->isAdmin() || ! $user->isAdmin(); + } +} diff --git a/app/Policies/WallOfLoveSubmissionPolicy.php b/app/Policies/WallOfLoveSubmissionPolicy.php new file mode 100644 index 000000000..2373ecb4a --- /dev/null +++ b/app/Policies/WallOfLoveSubmissionPolicy.php @@ -0,0 +1,49 @@ +can('ViewAny:WallOfLoveSubmission'); + } + + public function view(AuthUser $authUser, WallOfLoveSubmission $wallOfLoveSubmission): bool + { + return $authUser->can('View:WallOfLoveSubmission'); + } + + public function create(AuthUser $authUser): bool + { + return $authUser->can('Create:WallOfLoveSubmission'); + } + + public function update(AuthUser $authUser, WallOfLoveSubmission $wallOfLoveSubmission): bool + { + return $authUser->can('Update:WallOfLoveSubmission'); + } + + public function delete(AuthUser $authUser, WallOfLoveSubmission $wallOfLoveSubmission): bool + { + return $authUser->can('Delete:WallOfLoveSubmission'); + } + + public function deleteAny(AuthUser $authUser): bool + { + return $authUser->can('DeleteAny:WallOfLoveSubmission'); + } + + public function reorder(AuthUser $authUser): bool + { + return $authUser->can('Reorder:WallOfLoveSubmission'); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 54756cd1c..5285e48f6 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,8 +2,14 @@ namespace App\Providers; -// use Illuminate\Support\Facades\Gate; +use App\Models\User; +use App\Policies\SubscriptionItemPolicy; +use App\Policies\SubscriptionPolicy; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; +use Illuminate\Support\Facades\Gate; +use Laravel\Cashier\Subscription; +use Laravel\Cashier\SubscriptionItem; class AuthServiceProvider extends ServiceProvider { @@ -13,7 +19,8 @@ class AuthServiceProvider extends ServiceProvider * @var array */ protected $policies = [ - // + Subscription::class => SubscriptionPolicy::class, + SubscriptionItem::class => SubscriptionItemPolicy::class, ]; /** @@ -21,6 +28,39 @@ class AuthServiceProvider extends ServiceProvider */ public function boot(): void { - // + Gate::before(function (User $user, string $ability, array $arguments): ?bool { + if ($user->isAdmin()) { + return $this->isPanelPermission($ability) ? true : null; + } + + return $this->lacksPolicyMethod($ability, $arguments[0] ?? null) ? false : null; + }); + } + + /** + * Shield permissions look like "ViewAny:Plugin". Super admins hold all of them, + * while ordinary policy abilities (e.g. a customer's own support ticket) still + * go through their policies untouched. + */ + protected function isPanelPermission(string $ability): bool + { + return str_contains($ability, config('filament-shield.permissions.separator')); + } + + /** + * Filament allows anything a model has no policy for. Deny those to everyone + * but super admins, so a new admin section stays locked until it has a policy. + */ + protected function lacksPolicyMethod(string $ability, mixed $model): bool + { + $isModel = $model instanceof Model || (is_string($model) && is_subclass_of($model, Model::class)); + + if (! $isModel || Gate::has($ability)) { + return false; + } + + $policy = Gate::getPolicyFor($model); + + return $policy === null || ! method_exists($policy, $ability); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 0d2d26a4e..d8b339252 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,24 +2,52 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Dashboard; +use BezhanSalleh\FilamentShield\Facades\FilamentShield; +use BezhanSalleh\FilamentShield\FilamentShieldPlugin; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; -use Filament\Pages; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\Tables\Columns\CheckboxColumn; +use Filament\Tables\Columns\SelectColumn; +use Filament\Tables\Columns\TextInputColumn; +use Filament\Tables\Columns\ToggleColumn; use Filament\View\PanelsRenderHook; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; +use Illuminate\Support\Facades\Gate; use Illuminate\View\Middleware\ShareErrorsFromSession; class AdminPanelProvider extends PanelProvider { + public function boot(): void + { + FilamentShield::prohibitDestructiveCommands($this->app->isProduction()); + + $this->lockEditableColumnsWithoutUpdatePermission(); + } + + /** + * Editable table columns skip resource policies, so disable them for + * anyone who can't update the row they belong to. + */ + protected function lockEditableColumnsWithoutUpdatePermission(): void + { + foreach ([CheckboxColumn::class, SelectColumn::class, TextInputColumn::class, ToggleColumn::class] as $columnClass) { + $columnClass::configureUsing(fn ($column) => $column->disabled( + fn (?Model $record): bool => $record !== null && Gate::denies('update', $record), + )); + } + } + public function panel(Panel $panel): Panel { return $panel @@ -41,14 +69,14 @@ public function panel(Panel $panel): Panel ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') ->pages([ - Pages\Dashboard::class, + Dashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->widgets([ // ]) ->plugins([ - // + FilamentShieldPlugin::make(), ]) ->middleware([ EncryptCookies::class, diff --git a/composer.json b/composer.json index 2dde2da5a..4041f7772 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "require": { "php": "^8.2", "artesaos/seotools": "^1.2", + "bezhansalleh/filament-shield": "^4.3", "blade-ui-kit/blade-heroicons": "^2.3", "codeat3/blade-vaadin-icons": "^1.6", "embed/embed": "^4.4", diff --git a/composer.lock b/composer.lock index 5b7c53f06..f8492a194 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e5c1c570f82311914239ee32fd7b08a2", + "content-hash": "1ac7aadd50ee4f317fe595b7746ed5e1", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -293,6 +293,179 @@ }, "time": "2026-07-15T18:08:26+00:00" }, + { + "name": "bezhansalleh/filament-plugin-essentials", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/bezhanSalleh/filament-plugin-essentials.git", + "reference": "3ccec939e6e3fb51ce08cacf73c3cc3d3f59640c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bezhanSalleh/filament-plugin-essentials/zipball/3ccec939e6e3fb51ce08cacf73c3cc3d3f59640c", + "reference": "3ccec939e6e3fb51ce08cacf73c3cc3d3f59640c", + "shasum": "" + }, + "require": { + "filament/filament": "^4.0|^5.0", + "illuminate/contracts": "^11.28|^12.0|^13.0", + "php": "^8.2|^8.3", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^1.14", + "nunomaduro/collision": "^7.10.0|^8.1.1", + "orchestra/testbench": "^9.0.0|^10.0.0|^11.0.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-arch": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^3.5|^4.0", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1|^2.0", + "phpstan/phpstan-phpunit": "^1.3|^2.0", + "rector/rector": "^2.1", + "spatie/laravel-ray": "^1.40" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BezhanSalleh\\PluginEssentials\\PluginEssentialsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "BezhanSalleh\\PluginEssentials\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bezhan Salleh", + "email": "bezhan_salleh@yahoo.com", + "role": "Developer" + } + ], + "description": "A collection of essential traits that streamline Filament plugin development by taking care of the boilerplate, so you can focus on shipping real features faster", + "homepage": "https://github.com/bezhansalleh/filament-plugin-essentials", + "keywords": [ + "Bezhan Salleh", + "filament-plugin-essentials", + "laravel" + ], + "support": { + "issues": "https://github.com/bezhanSalleh/filament-plugin-essentials/issues", + "source": "https://github.com/bezhanSalleh/filament-plugin-essentials/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/bezhanSalleh", + "type": "github" + } + ], + "time": "2026-07-25T02:30:23+00:00" + }, + { + "name": "bezhansalleh/filament-shield", + "version": "4.3.1", + "source": { + "type": "git", + "url": "https://github.com/bezhanSalleh/filament-shield.git", + "reference": "1ff51f53985275f5bd37e9277cf810d391e7c3ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bezhanSalleh/filament-shield/zipball/1ff51f53985275f5bd37e9277cf810d391e7c3ca", + "reference": "1ff51f53985275f5bd37e9277cf810d391e7c3ca", + "shasum": "" + }, + "require": { + "bezhansalleh/filament-plugin-essentials": "^1.0", + "filament/filament": "^4.0|^5.0", + "illuminate/contracts": "^11.28|^12.0|^13.0", + "illuminate/support": "^11.28|^12.0|^13.0", + "php": "^8.2|^8.3", + "spatie/laravel-package-tools": "^1.0", + "spatie/laravel-permission": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^1.26", + "nunomaduro/collision": "^8.8", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.8|^4.0", + "pestphp/pest-plugin-laravel": "^3.2|^4.0", + "pestphp/pest-plugin-livewire": "^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^3.6|^4.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^11.0|^12.0", + "rector/jack": "^0.4.0", + "rector/rector": "^2.2", + "spatie/laravel-ray": "^1.43" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "FilamentShield": "BezhanSalleh\\FilamentShield\\Facades\\FilamentShield" + }, + "providers": [ + "BezhanSalleh\\FilamentShield\\FilamentShieldServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "BezhanSalleh\\FilamentShield\\": "src", + "BezhanSalleh\\FilamentShield\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bezhan Salleh", + "email": "bezhan_salleh@yahoo.com", + "role": "Developer" + } + ], + "description": "Filament support for `spatie/laravel-permission`.", + "homepage": "https://github.com/bezhansalleh/filament-shield", + "keywords": [ + "acl", + "bezhanSalleh", + "filament", + "filament-shield", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security" + ], + "support": { + "issues": "https://github.com/bezhanSalleh/filament-shield/issues", + "source": "https://github.com/bezhanSalleh/filament-shield/tree/4.3.1" + }, + "funding": [ + { + "url": "https://github.com/bezhanSalleh", + "type": "github" + } + ], + "time": "2026-07-25T03:31:26+00:00" + }, { "name": "blade-ui-kit/blade-heroicons", "version": "2.7.0", @@ -8433,6 +8606,94 @@ ], "time": "2026-05-19T14:06:37+00:00" }, + { + "name": "spatie/laravel-permission", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "60e8ed5b2fbf043c2264433fc2680c76b8b66aa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/60e8ed5b2fbf043c2264433fc2680c76b8b66aa6", + "reference": "60e8ed5b2fbf043c2264433fc2680c76b8b66aa6", + "shasum": "" + }, + "require": { + "illuminate/auth": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/octane": "^2.0", + "laravel/passport": "^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.1", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "8.x-dev", + "dev-master": "8.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 12 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-07-03T15:36:01+00:00" + }, { "name": "spatie/macroable", "version": "2.1.0", diff --git a/config/filament-shield.php b/config/filament-shield.php new file mode 100644 index 000000000..b6cd7ddb5 --- /dev/null +++ b/config/filament-shield.php @@ -0,0 +1,294 @@ + [ + 'slug' => 'shield/roles', + 'show_model_path' => true, + 'cluster' => null, + 'tabs' => [ + 'pages' => true, + 'widgets' => true, + 'resources' => true, + 'custom_permissions' => false, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Multi-Tenancy + |-------------------------------------------------------------------------- + | + | When your application supports teams, Shield will automatically detect + | and configure the tenant model during setup. This enables tenant-scoped + | roles and permissions throughout your application. + | + */ + + 'tenant_model' => null, + + /* + |-------------------------------------------------------------------------- + | User Model + |-------------------------------------------------------------------------- + | + | This value contains the class name of your user model. This model will + | be used for role assignments and must implement the HasRoles trait + | provided by the Spatie\Permission package. + | + */ + + 'auth_provider_model' => 'App\\Models\\User', + + /* + |-------------------------------------------------------------------------- + | Super Admin + |-------------------------------------------------------------------------- + | + | Here you may define a super admin that has unrestricted access to your + | application. You can choose to implement this via Laravel's gate system + | or as a traditional role with all permissions explicitly assigned. + | + */ + + 'super_admin' => [ + 'enabled' => true, + 'name' => 'super_admin', + 'define_via_gate' => false, + 'intercept_gate' => 'before', + ], + + /* + |-------------------------------------------------------------------------- + | Panel User + |-------------------------------------------------------------------------- + | + | When enabled, Shield will create a basic panel user role that can be + | assigned to users who should have access to your Filament panels but + | don't need any specific permissions beyond basic authentication. + | + */ + + 'panel_user' => [ + 'enabled' => false, + 'name' => 'panel_user', + ], + + /* + |-------------------------------------------------------------------------- + | Permission Builder + |-------------------------------------------------------------------------- + | + | You can customize how permission keys are generated to match your + | preferred naming convention and organizational standards. Shield uses + | these settings when creating permission names from your resources. + | + | Supported formats: snake, kebab, pascal, camel, upper_snake, lower_snake + | + | Note: The separator must not conflict with the case format's own + | delimiter. For example, `_` cannot be used with snake/lower_snake/ + | upper_snake, and `-` cannot be used with kebab. + | + | When `format_custom_permission_keys` is true (default), custom + | permissions defined below will have their keys formatted according to + | the case setting. If your custom permissions come from external sources + | (e.g. Terraform, Keycloak) and must remain unchanged, set this to false. + | When using the separator in custom permission definitions, each segment + | will be formatted independently (e.g. 'view:system_log' with pascal + | case becomes 'View:SystemLog'). + | + */ + + 'permissions' => [ + 'separator' => ':', + 'case' => 'pascal', + 'generate' => true, + 'format_custom_permission_keys' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Policies + |-------------------------------------------------------------------------- + | + | Shield can automatically generate Laravel policies for your resources. + | Generated policies mirror each model's location: models under + | app/Models map into the path below (keeping their nesting), models in + | any other "Models" directory get a sibling "Policies" directory, and + | vendor models fall back to the path below. When merge is enabled, the + | methods below will be combined with any resource-specific methods you + | define in the resources section. + | + */ + + 'policies' => [ + 'path' => app_path('Policies'), + 'merge' => true, + 'generate' => true, + 'methods' => [ + 'viewAny', 'view', 'create', 'update', 'delete', 'deleteAny', 'reorder', + ], + 'single_parameter_methods' => [ + 'viewAny', + 'create', + 'deleteAny', + 'reorder', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Localization + |-------------------------------------------------------------------------- + | + | Shield supports multiple languages out of the box. When enabled, you + | can provide translated labels for permissions to create a more + | localized experience for your international users. + | + */ + + 'localization' => [ + 'enabled' => false, + 'key' => 'filament-shield::filament-shield.resource_permission_prefixes_labels', + ], + + /* + |-------------------------------------------------------------------------- + | Resources + |-------------------------------------------------------------------------- + | + | Here you can fine-tune permissions for specific Filament resources. + | Use the 'manage' array to override the default policy methods for + | individual resources, giving you granular control over permissions. + | + */ + + 'resources' => [ + 'subject' => 'model', + 'manage' => [ + PluginResource::class => [ + 'approve', + 'reject', + 'messageDeveloper', + 'runReviewChecks', + 'convertToPaid', + 'grantToUser', + 'syncToSatis', + 'resync', + ], + ThirdPartySaleResource::class => [ + 'refund', + ], + ], + 'exclude' => [ + RoleResource::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Pages + |-------------------------------------------------------------------------- + | + | Most Filament pages only require view permissions. Pages listed in the + | exclude array will be skipped during permission generation and won't + | appear in your role management interface. + | + */ + + 'pages' => [ + 'subject' => 'class', + 'prefix' => 'view', + 'exclude' => [ + Dashboard::class, + AdminDashboard::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Widgets + |-------------------------------------------------------------------------- + | + | Like pages, widgets typically only need view permissions. Add widgets + | to the exclude array if you don't want them to appear in your role + | management interface. + | + */ + + 'widgets' => [ + 'subject' => 'class', + 'prefix' => 'view', + 'exclude' => [ + AccountWidget::class, + FilamentInfoWidget::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Permissions + |-------------------------------------------------------------------------- + | + | Sometimes you need permissions that don't map to resources, pages, or + | widgets. Define any custom permissions here and they'll be available + | when editing roles in your application. + | + | Keys are formatted per the Permission Builder settings above; set + | permissions.format_custom_permission_keys to false to use them as-is. + | + */ + + 'custom_permissions' => [], + + /* + |-------------------------------------------------------------------------- + | Entity Discovery + |-------------------------------------------------------------------------- + | + | By default, Shield only looks for entities in your default Filament + | panel. Enable these options if you're using multiple panels and want + | Shield to discover entities across all of them. + | + */ + + 'discovery' => [ + 'discover_all_resources' => false, + 'discover_all_widgets' => false, + 'discover_all_pages' => false, + ], + + /* + |-------------------------------------------------------------------------- + | Role Policy + |-------------------------------------------------------------------------- + | + | Shield can automatically register a policy for role management itself. + | This lets you control who can manage roles using Laravel's built-in + | authorization system. Requires a RolePolicy class in your app. + | + */ + + 'register_role_policy' => true, + +]; diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 000000000..8f1f452dc --- /dev/null +++ b/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/database/migrations/2026_10_05_064538_create_permission_tables.php b/database/migrations/2026_10_05_064538_create_permission_tables.php new file mode 100644 index 000000000..898627519 --- /dev/null +++ b/database/migrations/2026_10_05_064538_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/database/migrations/2026_10_05_070444_create_super_admin_role.php b/database/migrations/2026_10_05_070444_create_super_admin_role.php new file mode 100644 index 000000000..0c34088b5 --- /dev/null +++ b/database/migrations/2026_10_05_070444_create_super_admin_role.php @@ -0,0 +1,20 @@ +where('name', 'super_admin')->where('guard_name', 'web')->delete(); + } +}; diff --git a/tests/Feature/Filament/AdminRolesAndPermissionsTest.php b/tests/Feature/Filament/AdminRolesAndPermissionsTest.php new file mode 100644 index 000000000..3920d19b0 --- /dev/null +++ b/tests/Feature/Filament/AdminRolesAndPermissionsTest.php @@ -0,0 +1,344 @@ + ['env-admin@test.com']]); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Filament::setCurrentPanel('admin'); + } + + public function test_migrations_create_only_the_super_admin_role(): void + { + $this->assertSame(['super_admin'], Role::pluck('name')->all()); + } + + public function test_user_with_super_admin_role_is_an_admin_and_can_open_every_section(): void + { + $superAdmin = $this->superAdmin(); + + $this->assertTrue($superAdmin->isAdmin()); + + $this->actingAs($superAdmin); + + $this->get(PluginResource::getUrl('index'))->assertOk(); + $this->get(SalesResource::getUrl('index'))->assertOk(); + $this->get(UserResource::getUrl('index'))->assertOk(); + $this->get(RoleResource::getUrl('index'))->assertOk(); + $this->get(Companies::getUrl())->assertOk(); + $this->assertTrue(StatsOverview::canView()); + } + + public function test_user_listed_in_filament_users_keeps_full_access_without_a_role(): void + { + $envAdmin = User::factory()->create(['email' => 'env-admin@test.com']); + + $this->assertTrue($envAdmin->isAdmin()); + + $this->actingAs($envAdmin); + + $this->get(SalesResource::getUrl('index'))->assertOk(); + $this->get(RoleResource::getUrl('index'))->assertOk(); + } + + public function test_customer_without_a_role_cannot_enter_the_panel(): void + { + $customer = User::factory()->create(); + + $this->assertFalse($customer->isAdmin()); + $this->assertFalse($customer->canAccessPanel(Filament::getPanel('admin'))); + + $this->actingAs($customer)->get('/admin')->assertForbidden(); + } + + public function test_plugin_reviewer_can_list_plugins_but_not_open_sales_users_roles_or_companies(): void + { + $reviewer = $this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS); + + $this->assertFalse($reviewer->isAdmin()); + $this->assertTrue($reviewer->canAccessPanel(Filament::getPanel('admin'))); + + $this->actingAs($reviewer); + + $this->get(PluginResource::getUrl('index'))->assertOk(); + $this->get(SalesResource::getUrl('index'))->assertForbidden(); + $this->get(UserResource::getUrl('index'))->assertForbidden(); + $this->get(RoleResource::getUrl('index'))->assertForbidden(); + $this->get(Companies::getUrl())->assertForbidden(); + $this->assertFalse(StatsOverview::canView()); + } + + public function test_staff_who_cannot_see_any_dashboard_widget_land_on_their_first_section(): void + { + $this->actingAs($this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS)); + + $this->assertFalse(Dashboard::shouldRegisterNavigation()); + + Livewire::test(Dashboard::class)->assertRedirect(PluginResource::getUrl('index')); + } + + public function test_super_admin_still_gets_the_dashboard(): void + { + $this->actingAs($this->superAdmin()); + + $this->assertTrue(Dashboard::shouldRegisterNavigation()); + + $this->get(Dashboard::getUrl())->assertOk()->assertSeeLivewire(StatsOverview::class); + Livewire::test(Dashboard::class)->assertNoRedirect(); + } + + public function test_plugin_reviewer_can_reject_a_plugin(): void + { + Notification::fake(); + + $reviewer = $this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS); + $plugin = Plugin::factory()->pending()->free()->create(); + + Livewire::actingAs($reviewer) + ->test(EditPlugin::class, ['record' => $plugin->getRouteKey()]) + ->assertActionVisible('approve') + ->callAction('reject', data: ['rejection_reason' => 'Missing documentation.']) + ->assertHasNoActionErrors(); + + $this->assertSame(PluginStatus::Rejected, $plugin->fresh()->status); + } + + public function test_plugin_reviewer_cannot_touch_paid_plugin_settings(): void + { + $reviewer = $this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS); + $freePlugin = Plugin::factory()->approved()->free()->create(['repository_url' => 'https://github.com/acme/free']); + $paidPlugin = Plugin::factory()->approved()->paid()->create(); + + Livewire::actingAs($reviewer) + ->test(EditPlugin::class, ['record' => $freePlugin->getRouteKey()]) + ->assertActionHidden('convertToPaid') + ->assertActionHidden('runReviewChecks') + ->assertActionHidden('resync') + ->assertFormFieldDisabled('type') + ->assertFormFieldDisabled('tier'); + + Livewire::actingAs($reviewer) + ->test(EditPlugin::class, ['record' => $paidPlugin->getRouteKey()]) + ->assertActionHidden('grantToUser') + ->assertActionHidden('syncToSatis'); + + Livewire::actingAs($reviewer) + ->test(PricesRelationManager::class, ['ownerRecord' => $paidPlugin, 'pageClass' => EditPlugin::class]) + ->assertActionHidden(TestAction::make('create')->table()); + } + + public function test_super_admin_sees_every_plugin_action(): void + { + $freePlugin = Plugin::factory()->approved()->free()->create(['repository_url' => 'https://github.com/acme/free']); + + Livewire::actingAs($this->superAdmin()) + ->test(EditPlugin::class, ['record' => $freePlugin->getRouteKey()]) + ->assertActionVisible('convertToPaid') + ->assertActionVisible('runReviewChecks') + ->assertActionVisible('resync') + ->assertFormFieldEnabled('type') + ->assertFormFieldEnabled('tier'); + } + + public function test_role_without_update_permission_cannot_flip_plugin_toggles(): void + { + $viewer = $this->userWithRole(['ViewAny:Plugin', 'View:Plugin']); + $editor = $this->userWithRole(['ViewAny:Plugin', 'View:Plugin', 'Update:Plugin']); + $plugin = Plugin::factory()->pending()->free()->create(['featured' => false]); + + Livewire::actingAs($viewer) + ->test(ListPlugins::class) + ->call('updateTableColumnState', 'featured', (string) $plugin->getKey(), true); + + $this->assertFalse($plugin->fresh()->featured); + + Livewire::actingAs($editor) + ->test(ListPlugins::class) + ->call('updateTableColumnState', 'featured', (string) $plugin->getKey(), true); + + $this->assertTrue($plugin->fresh()->featured); + } + + public function test_relation_managers_without_their_own_policy_follow_the_owner_record(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $reviewer = $this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS); + $unrelatedStaff = $this->userWithRole(['ViewAny:Lead']); + + $this->actingAs($reviewer); + $this->assertTrue(ActivitiesRelationManager::canViewForRecord($plugin, EditPlugin::class)); + + $this->actingAs($unrelatedStaff); + $this->assertFalse(ActivitiesRelationManager::canViewForRecord($plugin, EditPlugin::class)); + } + + public function test_models_without_a_policy_are_locked_to_super_admins(): void + { + $this->actingAs($this->userWithRole(self::PLUGIN_REVIEW_PERMISSIONS)); + $this->assertTrue(get_authorization_response('viewAny', PluginActivity::class)->denied()); + + $this->actingAs($this->superAdmin()); + $this->assertTrue(get_authorization_response('viewAny', PluginActivity::class)->allowed()); + } + + public function test_staff_who_manage_users_cannot_edit_super_admins_or_assign_roles(): void + { + $userManager = $this->userWithRole(['ViewAny:User', 'View:User', 'Update:User']); + $superAdmin = $this->superAdmin(); + $customer = User::factory()->create(); + + $this->assertTrue($userManager->can('update', $customer)); + $this->assertFalse($userManager->can('update', $superAdmin)); + $this->assertFalse($userManager->canImpersonate()); + + $this->actingAs($userManager) + ->get(UserResource::getUrl('edit', ['record' => $superAdmin])) + ->assertForbidden(); + + Livewire::actingAs($userManager) + ->test(EditUser::class, ['record' => $customer->getRouteKey()]) + ->assertFormFieldHidden('roles'); + } + + public function test_super_admin_can_assign_roles_from_the_users_page(): void + { + $role = Role::create(['name' => 'test_role']); + $customer = User::factory()->create(); + + Livewire::actingAs($this->superAdmin()) + ->test(EditUser::class, ['record' => $customer->getRouteKey()]) + ->assertFormFieldVisible('roles') + ->fillForm(['roles' => [$role->id]]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertTrue($customer->fresh()->hasRole('test_role')); + $this->assertTrue($customer->fresh()->canAccessPanel(Filament::getPanel('admin'))); + } + + public function test_super_admin_role_cannot_be_edited_or_deleted(): void + { + $superAdmin = $this->superAdmin(); + $superAdminRole = Role::findByName(config('filament-shield.super_admin.name')); + $otherRole = Role::create(['name' => 'test_role']); + + $this->assertFalse($superAdmin->can('update', $superAdminRole)); + $this->assertFalse($superAdmin->can('delete', $superAdminRole)); + $this->assertTrue($superAdmin->can('update', $otherRole)); + } + + public function test_ticket_viewer_without_update_permission_cannot_reply(): void + { + $viewer = $this->userWithRole(['ViewAny:SupportTicket', 'View:SupportTicket']); + $ticket = SupportTicket::factory()->create(); + + Livewire::actingAs($viewer) + ->test(TicketRepliesWidget::class, ['record' => $ticket]) + ->set('newMessage', 'Hello') + ->call('sendReply') + ->assertForbidden(); + + $this->assertSame(0, $ticket->replies()->count()); + } + + public function test_custom_roles_do_not_override_customer_facing_ticket_rules(): void + { + $staff = $this->userWithRole(['ViewAny:SupportTicket', 'View:SupportTicket', 'Update:SupportTicket']); + $ticket = SupportTicket::factory()->create(); + + $this->assertTrue($staff->can('view', $ticket)); + $this->assertFalse($staff->can('reply', $ticket)); + $this->assertFalse($staff->can('delete', $ticket)); + $this->assertFalse($this->superAdmin()->can('reply', $ticket)); + } + + /** + * Pages and widgets have no policy to fall back on, so each one needs a + * Shield trait or it would be open to every role. + */ + public function test_every_admin_page_and_widget_requires_a_permission(): void + { + $panel = Filament::getPanel('admin'); + + foreach (array_diff($panel->getPages(), config('filament-shield.pages.exclude')) as $page) { + $this->assertContains(HasPageShield::class, class_uses_recursive($page), "{$page} must use HasPageShield."); + } + + foreach (array_diff($panel->getWidgets(), config('filament-shield.widgets.exclude')) as $widget) { + $this->assertContains(HasWidgetShield::class, class_uses_recursive($widget), "{$widget} must use HasWidgetShield."); + } + } + + private function superAdmin(): User + { + $user = User::factory()->create(); + $user->assignRole(Role::findOrCreate(config('filament-shield.super_admin.name'))); + + return $user; + } + + /** + * @param list $permissions + */ + private function userWithRole(array $permissions): User + { + $role = Role::findOrCreate('test_role_'.count(Role::all())); + + foreach ($permissions as $permission) { + $role->givePermissionTo(Permission::findOrCreate($permission)); + } + + $user = User::factory()->create(); + $user->assignRole($role); + + return $user; + } +}