From 6cc46cf6529ae81b1f85c4e2c2579ecd10e6473b Mon Sep 17 00:00:00 2001 From: "Ralph J. Smit" <59207045+ralphjsmit@users.noreply.github.com> Date: Thu, 28 Sep 2023 20:48:12 +0200 Subject: [PATCH 1/5] Allow limiting provider plans per package --- app/Filament/Resources/PackageResource.php | 106 ++++++++++- .../PackageResource/Pages/EditPackage.php | 9 +- app/Http/Controllers/ServerController.php | 34 ++-- app/Http/Middleware/HasAccessToThisGroup.php | 10 +- app/Models/Package.php | 19 +- app/Models/PackageProvider.php | 26 +++ ...632_create_package_provider_plan_table.php | 28 +++ routes/web.php | 171 ++++++++++-------- .../Http/Controllers/ServerControllerTest.php | 69 +++++-- 9 files changed, 355 insertions(+), 117 deletions(-) create mode 100644 app/Models/PackageProvider.php create mode 100644 database/migrations/2023_09_28_115632_create_package_provider_plan_table.php diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index cfad576..a3b6ba0 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -2,14 +2,17 @@ namespace App\Filament\Resources; -use Filament\Forms; -use Filament\Tables; -use App\Models\Package; -use Filament\Forms\Form; -use Filament\Tables\Table; -use Filament\Resources\Resource; use App\Filament\Resources\PackageResource\Pages; use App\Filament\Resources\PackageResource\RelationManagers; +use App\Models\Package; +use App\Models\Provider; +use App\Models\ProviderPlan; +use Filament\Forms; +use Filament\Forms\Form; +use Filament\Notifications\Notification; +use Filament\Resources\Resource; +use Filament\Tables; +use Filament\Tables\Table; use Illuminate\Support\HtmlString; class PackageResource extends Resource @@ -66,6 +69,7 @@ class PackageResource extends Resource Forms\Components\Grid::make() ->schema([ Forms\Components\Section::make(__('Server permissions')) + ->icon(ServerResource::getNavigationIcon()) ->schema([ Forms\Components\Checkbox::make('server_permissions.create') ->reactive() @@ -80,6 +84,7 @@ class PackageResource extends Resource ]) ->columnSpan(1), Forms\Components\Section::make(__('Site permissions')) + ->icon(SiteResource::getNavigationIcon()) ->schema([ Forms\Components\Checkbox::make('site_permissions.create') ->label('Allow site creation') @@ -98,9 +103,94 @@ class PackageResource extends Resource ->schema([ Forms\Components\Section::make(__('Available server providers')) ->description(__('These server providers will be available for users that are attached to this package.')) + ->icon(ProviderResource::getNavigationIcon()) ->schema([ Forms\Components\CheckboxList::make('providers') ->relationship('providers', 'name') + ->reactive(), + Forms\Components\Grid::make(1) + ->schema([ + Forms\Components\Actions::make([ + Forms\Components\Actions\Action::make('manage_provider_plans') + ->label(__('Manage provider plans')) + ->icon('heroicon-o-adjustments-horizontal') + ->form(function (Package $record) { + return $record->providers->sortBy('name')->map(function (Provider $provider) { + return Forms\Components\Section::make($provider->label) + ->description('Select the plans that should be available for this provider on this package.') + ->icon(ProviderResource::getNavigationIcon()) + ->statePath($provider->id) + ->schema([ + Forms\Components\Toggle::make('select_specific_provider_plans') + ->label(__('Select subset')) + ->helperText(__('Check this box if you want to limit the provider plans available on this package.')) + ->default(false) + ->reactive() + ->afterStateUpdated(function (Forms\Components\Toggle $component, Forms\Set $set) use ($provider) { + $set( + path: "provider_plans", + state: $component->getState() ? $provider->plans->pluck('id') : [], + ); + }), + Forms\Components\CheckboxList::make("provider_plans") + ->label(__('Select plans')) + ->options(fn() => $provider->plans->mapWithKeys(fn(ProviderPlan $providerPlan) => [$providerPlan->id => $providerPlan->label ?? $providerPlan->plan_id])->all()) + ->visible(fn(Forms\Get $get) => $get('select_specific_provider_plans')) + ->reactive() + ->bulkToggleable() + ->columns(2) + ]) + ->collapsible(); + })->all(); + }) + ->fillForm(function (Package $record) { + return $record->providers->mapWithKeys(function (Provider $provider) use ($record) { + $providerPlanIds = $record->providerPlans()->whereBelongsTo($provider)->pluck('provider_plans.id'); + + return [$provider->id => [ + 'select_specific_provider_plans' => $providerPlanIds->isNotEmpty(), + 'provider_plans' => $providerPlanIds->all(), + ]]; + })->all(); + }) + ->action(function (Package $record, array $data) { + $providerPlanIds = collect($data) + ->where('select_specific_provider_plans', true) + ->pluck('provider_plans') + ->flatten(); + + $record->providerPlans()->sync($providerPlanIds); + + Notification::make() + ->title(__('Provider plans saved')) + ->success() + ->send(); + }) + ->modalSubmitActionLabel('Save') + ->color('gray') + ->disabled(function (Package $record, Forms\Get $get) { + $providers = collect($get('providers')) + ->map(fn(string $id): int => (int)$id) + ->sort(); + + return $record->providers->pluck('id')->map(fn(string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); + }) + ]), + Forms\Components\Placeholder::make('save_warning') + ->content(__('You\'ve changed the available server providers. Please save your changes before you can manage the provider plans.')) + ->visible(function (Pages\EditPackage $livewire, Forms\Get $get) { + /** @var Package $package */ + $package = $livewire->getRecord(); + + $providers = collect($get('providers')) + ->map(fn(string $id): int => (int)$id) + ->sort(); + + return $package->providers->pluck('id')->map(fn(string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); + }) + ->hiddenLabel(), + ]) + ->hiddenOn('create'), ]) ->columnSpan(1) ]) @@ -127,10 +217,10 @@ class PackageResource extends Resource return "Attached to stripe - {$record->price_monthly} {$record->currency}"; }), Tables\Columns\TextColumn::make('maximum_sites') - ->formatStateUsing(fn (int $state) => $state === 0 ? __('Unlimited') : $state) + ->formatStateUsing(fn(int $state) => $state === 0 ? __('Unlimited') : $state) ->label(__('Maximum sites')), Tables\Columns\TextColumn::make('maximum_servers') - ->formatStateUsing(fn (int $state) => $state === 0 ? __('Unlimited') : $state) + ->formatStateUsing(fn(int $state) => $state === 0 ? __('Unlimited') : $state) ->label(__('Maximum servers')), Tables\Columns\TextColumn::make('users_count') ->counts('users'), diff --git a/app/Filament/Resources/PackageResource/Pages/EditPackage.php b/app/Filament/Resources/PackageResource/Pages/EditPackage.php index c31e2b0..02d35d4 100644 --- a/app/Filament/Resources/PackageResource/Pages/EditPackage.php +++ b/app/Filament/Resources/PackageResource/Pages/EditPackage.php @@ -2,9 +2,9 @@ namespace App\Filament\Resources\PackageResource\Pages; +use App\Filament\Resources\PackageResource; use Filament\Actions; use Filament\Resources\Pages\EditRecord; -use App\Filament\Resources\PackageResource; class EditPackage extends EditRecord { @@ -16,4 +16,11 @@ class EditPackage extends EditRecord Actions\DeleteAction::make(), ]; } + + public function afterSave(): void + { + // Necessary to refresh, in order to load the updated saved relationships and + // correctly show or hide the "manage provider plans" warning placeholder. + $this->getRecord()->refresh(); + } } diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 28f1b90..082c041 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -2,15 +2,17 @@ namespace App\Http\Controllers; -use App\Models\Server; -use Illuminate\Http\Request; -use App\Jobs\Servers\DeleteServer; -use Illuminate\Support\Facades\Auth; -use Illuminate\Http\RedirectResponse; -use App\Http\Resources\ServerResource; -use App\DataTransferObjects\ServerData; use App\Actions\Server\CreateServerAction; +use App\DataTransferObjects\ServerData; use App\Http\Requests\ServerUpdateRequest; +use App\Http\Resources\ServerResource; +use App\Jobs\Servers\DeleteServer; +use App\Models\ProviderPlan; +use App\Models\Server; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; class ServerController extends Controller { @@ -101,19 +103,29 @@ class ServerController extends Controller public function plansAndRegions(Request $request, $providerId) { - $provider = $request->user()->package->providers()->findOrFail($providerId); + $package = $request->user()->package; - $regions = $provider->regions() + $provider = $package->providers()->findOrFail($providerId); + + $regions = $provider + ->regions() ->when($provider->allowed_regions, function ($query) use ($provider) { return $query->whereIn('id', $provider->allowed_regions); }) ->pluck('label', 'id'); - $plans = $provider->plans() + $plans = $provider + ->plans() ->when($provider->allowed_plans, function ($query) use ($provider) { return $query->whereIn('id', $provider->allowed_plans); }) - ->pluck('label', 'id'); + ->when($package->providerPlans()->whereBelongsTo($provider)->exists(), function (Builder $query) use ($provider, $package) { + return $query->whereIn('id', $package->providerPlans()->whereBelongsTo($provider)->pluck('provider_plans.id')); + }) + ->get() + ->mapWithKeys(function (ProviderPlan $providerPlan) { + return [$providerPlan->id => $providerPlan->label ?? $providerPlan->plan_id]; + }); return [ 'regions' => $regions, diff --git a/app/Http/Middleware/HasAccessToThisGroup.php b/app/Http/Middleware/HasAccessToThisGroup.php index d405390..8b4d06f 100644 --- a/app/Http/Middleware/HasAccessToThisGroup.php +++ b/app/Http/Middleware/HasAccessToThisGroup.php @@ -3,20 +3,20 @@ namespace App\Http\Middleware; use Closure; -use Illuminate\Support\Arr; use Illuminate\Http\Request; +use Illuminate\Support\Arr; class HasAccessToThisGroup { public function handle(Request $request, Closure $next, $group) { if ($group === 'servers') { - $package = $request->user()->package ?? []; + $package = $request->user()->package ?? null; if ( - !Arr::get($package->server_permissions, 'create', false) && - !Arr::get($package->server_permissions, 'update', false) && - !Arr::get($package->server_permissions, 'delete', false) + !Arr::get($package->server_permissions ?? [], 'create', false) && + !Arr::get($package->server_permissions ?? [], 'update', false) && + !Arr::get($package->server_permissions ?? [], 'delete', false) ) { abort(404); } diff --git a/app/Models/Package.php b/app/Models/Package.php index ee5abe0..1a159f6 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -3,8 +3,10 @@ namespace App\Models; use App\Casts\PermissionCast; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Package extends Model { @@ -39,19 +41,24 @@ class Package extends Model 'server_permissions' => PermissionCast::class, ]; - public function users() + public function users(): HasMany { return $this->hasMany(User::class); } - public function providers() + public function providers(): BelongsToMany { - return $this->belongsToMany(Provider::class); + return $this->belongsToMany(Provider::class)->using(PackageProvider::class); } - protected static function booted() + public function providerPlans(): BelongsToMany { - static::deleting(function ($package) { + return $this->belongsToMany(ProviderPlan::class); + } + + protected static function booted(): void + { + static::deleting(function (self $package) { $package->users()->update(['package_id' => null]); }); } diff --git a/app/Models/PackageProvider.php b/app/Models/PackageProvider.php new file mode 100644 index 0000000..e077bf9 --- /dev/null +++ b/app/Models/PackageProvider.php @@ -0,0 +1,26 @@ +package->providerPlans()->whereBelongsTo($packageProvider->provider)->detach(); + }); + } + + public function package(): BelongsTo + { + return $this->belongsTo(Package::class); + } + + public function provider(): BelongsTo + { + return $this->belongsTo(Provider::class); + } +} diff --git a/database/migrations/2023_09_28_115632_create_package_provider_plan_table.php b/database/migrations/2023_09_28_115632_create_package_provider_plan_table.php new file mode 100644 index 0000000..c422155 --- /dev/null +++ b/database/migrations/2023_09_28_115632_create_package_provider_plan_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('package_id')->constrained(); + $table->foreignId('provider_plan_id')->constrained(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('package_provider_plan'); + } +}; diff --git a/routes/web.php b/routes/web.php index 2b9d37c..61410a0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,22 +1,43 @@ name('password-creation') +Route::get('password-creation', [CreatePasswordController::class, 'index'])->name('password-creation') ->middleware(['signed', 'guest']); -Route::post('password-creation', 'Auth\CreatePasswordController@start')->name('password-creation.start') +Route::post('password-creation', [CreatePasswordController::class, 'start'])->name('password-creation.start') ->middleware('guest'); -Route::get('installation-incomplete', 'PageController@installationIncomplete')->name('installation-incomplete'); +Route::get('installation-incomplete', [PageController::class, 'installationIncomplete'])->name('installation-incomplete'); -Route::get('page/{slug}', 'PageController@show')->name('page.show'); +Route::get('page/{slug}', [PageController::class, 'show'])->name('page.show'); // All auth protected routes Route::group(['middleware' => ['auth', 'auth.blocked']], function () { @@ -24,92 +45,92 @@ Route::group(['middleware' => ['auth', 'auth.blocked']], function () { Route::post('confirm-2fa', [AuthenticateTwoFactorController::class, 'confirm'])->name('auth.confirm-2fa.confirm'); Route::middleware('auth.2fa')->group(function () { - Route::get('/', 'DashboardController@index')->name('dashboard'); - Route::get('search', 'SearchController@handle')->name('search'); + Route::get('/', [DashboardController::class, 'index'])->name('dashboard'); + Route::get('search', [SearchController::class, 'handle'])->name('search'); // Site routes Route::group(['prefix' => 'sites', 'as' => 'sites.'], function () { - Route::get('/', 'SiteController@index')->name('index'); - Route::get('{site}', 'SiteController@show')->name('show'); - Route::delete('{site}', 'SiteController@destroy')->name('delete'); - Route::post('/', 'SiteController@store')->name('store'); - Route::post('{site}/request-ftp-password', 'SiteController@requestFtpPassword')->name('request-ftp-password'); + Route::get('/', [SiteController::class, 'index'])->name('index'); + Route::get('{site}', [SiteController::class, 'show'])->name('show'); + Route::delete('{site}', [SiteController::class, 'destroy'])->name('delete'); + Route::post('/', [SiteController::class, 'store'])->name('store'); + Route::post('{site}/request-ftp-password', [SiteController::class, 'requestFtpPassword'])->name('request-ftp-password'); - Route::get('{site}/settings', 'SiteSettingController@show')->name('settings.show'); - Route::patch('{site}/settings', 'SiteSettingController@update')->name('settings.update'); - Route::patch('{site}/settings/php-version', 'SiteSettingController@changePhpVersion')->name('settings.php-version'); + Route::get('{site}/settings', [SiteSettingController::class, 'show'])->name('settings.show'); + Route::patch('{site}/settings', [SiteSettingController::class, 'update'])->name('settings.update'); + Route::patch('{site}/settings/php-version', [SiteSettingController::class, 'changePhpVersion'])->name('settings.php-version'); // Site apps Route::group(['prefix' => '{site}/apps', 'as' => 'apps.'], function () { - Route::get('/', 'SiteAppController@index')->name('index'); - Route::post('/', 'SiteAppController@store')->name('store'); - Route::delete('/', 'SiteAppController@destroy')->name('delete'); + Route::get('/', [SiteAppController::class, 'index'])->name('index'); + Route::post('/', [SiteAppController::class, 'store'])->name('store'); + Route::delete('/', [SiteAppController::class, 'destroy'])->name('delete'); }); // Site databases Route::group(['prefix' => '{site}/databases', 'as' => 'databases.'], function () { - Route::get('/', 'SiteDatabaseController@index')->name('index'); - Route::post('/', 'SiteDatabaseController@store')->name('store'); - Route::delete('{database}', 'SiteDatabaseController@destroy')->name('delete'); + Route::get('/', [SiteDatabaseController::class, 'index'])->name('index'); + Route::post('/', [SiteDatabaseController::class, 'store'])->name('store'); + Route::delete('{database}', [SiteDatabaseController::class, 'destroy'])->name('delete'); }); // Site cronjobs Route::group(['prefix' => '{site}/cronjobs', 'as' => 'cronjobs.'], function () { - Route::get('/', 'SiteCronjobController@index')->name('index'); - Route::post('/', 'SiteCronjobController@store')->name('store'); - Route::delete('{cronjob}', 'SiteCronjobController@destroy')->name('delete'); + Route::get('/', [SiteCronjobController::class, 'index'])->name('index'); + Route::post('/', [SiteCronjobController::class, 'store'])->name('store'); + Route::delete('{cronjob}', [SiteCronjobController::class, 'destroy'])->name('delete'); }); // Site redirects Route::group(['prefix' => '{site}/redirects', 'as' => 'redirects.'], function () { - Route::get('/', 'SiteRedirectController@index')->name('index'); - Route::post('/', 'SiteRedirectController@store')->name('store'); - Route::delete('{redirect}', 'SiteRedirectController@destroy')->name('delete'); + Route::get('/', [SiteRedirectController::class, 'index'])->name('index'); + Route::post('/', [SiteRedirectController::class, 'store'])->name('store'); + Route::delete('{redirect}', [SiteRedirectController::class, 'destroy'])->name('delete'); }); // Site SSL Route::group(['prefix' => '{site}/certificates', 'as' => 'certificates.'], function () { - Route::get('/', 'SiteCertificateController@index')->name('index'); - Route::post('/', 'SiteCertificateController@store')->name('store'); - Route::delete('{certificate}', 'SiteCertificateController@destroy')->name('delete'); + Route::get('/', [SiteCertificateController::class, 'index'])->name('index'); + Route::post('/', [SiteCertificateController::class, 'store'])->name('store'); + Route::delete('{certificate}', [SiteCertificateController::class, 'destroy'])->name('delete'); }); // Site aliases Route::group(['prefix' => '{site}/aliases', 'as' => 'aliases.'], function () { - Route::get('/', 'SiteAliasController@index')->name('index'); - Route::post('/', 'SiteAliasController@store')->name('store'); - Route::delete('{alias}', 'SiteAliasController@destroy')->name('delete'); + Route::get('/', [SiteAliasController::class, 'index'])->name('index'); + Route::post('/', [SiteAliasController::class, 'store'])->name('store'); + Route::delete('{alias}', [SiteAliasController::class, 'destroy'])->name('delete'); }); // Site DNS Route::group(['prefix' => '{site}/dns', 'as' => 'dns.'], function () { - Route::get('/', 'SiteDnsController@index')->name('index'); - Route::get('records', 'SiteDnsController@records')->name('records'); - Route::post('/', 'SiteDnsController@store')->name('store'); - Route::delete('{record}', 'SiteDnsController@destroy')->name('delete'); - Route::put('{record}', 'SiteDnsController@update')->name('update'); + Route::get('/', [SiteDnsController::class, 'index'])->name('index'); + Route::get('records', [SiteDnsController::class, 'records'])->name('records'); + Route::post('/', [SiteDnsController::class, 'store'])->name('store'); + Route::delete('{record}', [SiteDnsController::class, 'destroy'])->name('delete'); + Route::put('{record}', [SiteDnsController::class, 'update'])->name('update'); }); }); // Server routes Route::group(['prefix' => 'servers', 'as' => 'servers.', 'middleware' => 'has.access:servers'], function () { - Route::get('/', 'ServerController@index')->name('index'); - Route::get('{provider}/plans-and-regions', 'ServerController@plansAndRegions')->name('plans-and-regions'); - Route::get('{server}', 'ServerController@show')->name('show'); - Route::get('{server}/settings', 'ServerController@show')->name('settings.show'); - Route::patch('{server}/settings', 'ServerController@update')->name('settings.update'); - Route::post('/', 'ServerController@store')->name('store'); + Route::get('/', [ServerController::class, 'index'])->name('index'); + Route::get('{provider}/plans-and-regions', [ServerController::class, 'plansAndRegions'])->name('plans-and-regions'); + Route::get('{server}', [ServerController::class, 'show'])->name('show'); + Route::get('{server}/settings', [ServerController::class, 'show'])->name('settings.show'); + Route::patch('{server}/settings', [ServerController::class, 'update'])->name('settings.update'); + Route::post('/', [ServerController::class, 'store'])->name('store'); - Route::get('{server}/settings', 'ServerSettingController@show')->name('settings.show'); + Route::get('{server}/settings', [ServerSettingController::class, 'show'])->name('settings.show'); - Route::delete('{server}', 'ServerController@destroy')->name('delete'); + Route::delete('{server}', [ServerController::class, 'destroy'])->name('delete'); }); // Profile routes Route::group(['prefix' => 'profile', 'as' => 'profile.', 'namespace' => 'Profile'], function () { - Route::get('/', 'ProfileController@index')->name('index'); - Route::patch('/', 'ProfileController@update')->name('update'); - Route::delete('destroy', 'ProfileController@destroy')->name('delete-account'); + Route::get('/', [ProfileController::class, 'index'])->name('index'); + Route::patch('/', [ProfileController::class, 'update'])->name('update'); + Route::delete('destroy', [ProfileController::class, 'destroy'])->name('delete-account'); // Security Route::group(['prefix' => 'security', 'as' => 'security.'], function () { @@ -126,53 +147,53 @@ Route::group(['middleware' => ['auth', 'auth.blocked']], function () { // Settings Route::group(['prefix' => 'settings', 'as' => 'settings.'], function () { - Route::get('/', 'ProfileSettingController@index')->name('index'); - Route::patch('/', 'ProfileSettingController@update')->name('update'); + Route::get('/', [ProfileSettingController::class, 'index'])->name('index'); + Route::patch('/', [ProfileSettingController::class, 'update'])->name('update'); }); // Integrations Route::group(['prefix' => 'integrations', 'as' => 'integrations.'], function () { - Route::get('/', 'ProfileIntegrationController@index')->name('index'); - Route::post('/', 'ProfileIntegrationController@store')->name('store'); - Route::delete('{provider}', 'ProfileIntegrationController@destroy')->name('destroy'); + Route::get('/', [ProfileIntegrationController::class, 'index'])->name('index'); + Route::post('/', [ProfileIntegrationController::class, 'store'])->name('store'); + Route::delete('{provider}', [ProfileIntegrationController::class, 'destroy'])->name('destroy'); }); if (config('cashier.key') && config('cashier.secret')) { Route::group(['prefix' => 'billing', 'as' => 'billing.'], function () { - Route::get('/', 'ProfileBillingController@index')->name('index'); + Route::get('/', [ProfileBillingController::class, 'index'])->name('index'); - Route::post('card/update', 'ProfileBillingController@updateCard')->name('update.card'); - Route::delete('card', 'ProfileBillingController@deleteCard')->name('delete.card'); - Route::post('plan/update', 'ProfileBillingController@updatePlan')->name('update.plan'); - Route::delete('plan/cancel', 'ProfileBillingController@cancel')->name('cancel.plan'); - Route::get('invoices', 'ProfileBillingController@invoices')->name('invoices'); - Route::get('invoices/{id}/pdf', 'ProfileBillingController@pdf')->name('invoices.pdf'); + Route::post('card/update', [ProfileBillingController::class, 'updateCard'])->name('update.card'); + Route::delete('card', [ProfileBillingController::class, 'deleteCard'])->name('delete.card'); + Route::post('plan/update', [ProfileBillingController::class, 'updatePlan'])->name('update.plan'); + Route::delete('plan/cancel', [ProfileBillingController::class, 'cancel'])->name('cancel.plan'); + Route::get('invoices', [ProfileBillingController::class, 'invoices'])->name('invoices'); + Route::get('invoices/{id}/pdf', [ProfileBillingController::class, 'pdf'])->name('invoices.pdf'); }); } - Route::post('toggle-theme', 'ProfileController@toggleTheme')->name('toggle-theme'); + Route::post('toggle-theme', [ProfileController::class, 'toggleTheme'])->name('toggle-theme'); - Route::post('request-ftp-password', 'ProfileController@requestFtpPassword')->name('request-ftp-password'); + Route::post('request-ftp-password', [ProfileController::class, 'requestFtpPassword'])->name('request-ftp-password'); }); // Support routes if (setting('support')) { Route::group(['prefix' => 'support', 'as' => 'support.'], function () { - Route::get('/', 'SupportController@index')->name('index'); - Route::get('create', 'SupportController@create')->name('create'); - Route::get('closed', 'SupportController@indexClosed')->name('index.closed'); - Route::post('/', 'SupportController@store')->name('store'); - Route::get('{support}', 'SupportController@show')->name('show'); - Route::post('{support}/reply', 'SupportController@reply')->name('reply'); - Route::post('{support}/close', 'SupportController@close')->name('close'); + Route::get('/', [SupportController::class, 'index'])->name('index'); + Route::get('create', [SupportController::class, 'create'])->name('create'); + Route::get('closed', [SupportController::class, 'indexClosed'])->name('index.closed'); + Route::post('/', [SupportController::class, 'store'])->name('store'); + Route::get('{support}', [SupportController::class, 'show'])->name('show'); + Route::post('{support}/reply', [SupportController::class, 'reply'])->name('reply'); + Route::post('{support}/close', [SupportController::class, 'close'])->name('close'); }); } if (setting('documentation')) { Route::group(['prefix' => 'documentation', 'as' => 'documentation.'], function () { - Route::get('/', 'DocumentationController@index')->name('index'); - Route::get('{documentationCategory}', 'DocumentationController@show')->name('show'); - Route::get('{documentationCategory}/article/{documentationItem}', 'DocumentationController@showArticle')->name('article.show'); + Route::get('/', [DocumentationController::class, 'index'])->name('index'); + Route::get('{documentationCategory}', [DocumentationController::class, 'show'])->name('show'); + Route::get('{documentationCategory}/article/{documentationItem}', [DocumentationController::class, 'showArticle'])->name('article.show'); }); } }); diff --git a/tests/Feature/Http/Controllers/ServerControllerTest.php b/tests/Feature/Http/Controllers/ServerControllerTest.php index 3df7715..99003d7 100644 --- a/tests/Feature/Http/Controllers/ServerControllerTest.php +++ b/tests/Feature/Http/Controllers/ServerControllerTest.php @@ -1,19 +1,20 @@ withPackage(fn (PackageFactory $factory) => $factory->has(Provider::factory()->withRegion()->withPlan()))->create() + $user = User::factory()->withPackage(fn(PackageFactory $factory) => $factory->has(Provider::factory()->withRegion()->withPlan()))->create() ); $provider = Provider::sole(); @@ -73,13 +74,13 @@ it('can create a new server', function () { 'database_type' => 'postgresql', ]); - Mail::assertQueued(AdminServerCreatedEmail::class, fn (AdminServerCreatedEmail $mail) => $mail->to[0]['address'] === $adminUsers[0]->email); - Mail::assertQueued(AdminServerCreatedEmail::class, fn (AdminServerCreatedEmail $mail) => $mail->to[0]['address'] === $adminUsers[1]->email); + Mail::assertQueued(AdminServerCreatedEmail::class, fn(AdminServerCreatedEmail $mail) => $mail->to[0]['address'] === $adminUsers[0]->email); + Mail::assertQueued(AdminServerCreatedEmail::class, fn(AdminServerCreatedEmail $mail) => $mail->to[0]['address'] === $adminUsers[1]->email); }); it('cannot create a server without permissions', function () { actingAs( - $user = User::factory()->withPackage(fn (PackageFactory $factory) => $factory->serverPermissions(['create' => false,])->has(Provider::factory()->withRegion()->withPlan()))->create() + $user = User::factory()->withPackage(fn(PackageFactory $factory) => $factory->serverPermissions(['create' => false,])->has(Provider::factory()->withRegion()->withPlan()))->create() ); expect($user->can('create', Server::class))->toBeFalse(); @@ -101,3 +102,49 @@ it('cannot create a server without permissions', function () { // However, if the validation fails, we get an HTTP failed assertion for a stray request. ->assertOk(); }); + +it('can list only server provider plans that are allowed in the package', function () { + $provider = Provider::factory() + ->withRegion() + ->has(ProviderPlan::factory()->set('label', 'Provider Plan A'), 'plans') + ->has(ProviderPlan::factory()->set('label', 'Provider Plan B'), 'plans') + ->create(); + + $providerRegion = $provider->regions->sole(); + + [$providerPlanA, $providerPlanB] = $provider->plans; + + $package = Package::factory() + ->hasAttached($provider) + ->serverPermissions() + ->create(); + + actingAs($user = User::factory()->set('package_id', $package)->create()); + + // No provider plans attached to package for this provider, so all provider plans should be visible. + get(route('servers.plans-and-regions', ['provider' => $provider])) + ->assertOk() + ->assertExactJson([ + 'regions' => [ + $providerRegion->getKey() => $providerRegion->label, + ], + 'plans' => [ + $providerPlanA->getKey() => 'Provider Plan A', + $providerPlanB->getKey() => 'Provider Plan B', + ] + ]); + + $package->providerPlans()->attach($providerPlanB); + + // Only provider plan B for this provider attached to package, so only provider plan B should be visible + get(route('servers.plans-and-regions', ['provider' => $provider])) + ->assertOk() + ->assertExactJson([ + 'regions' => [ + $providerRegion->getKey() => $providerRegion->label, + ], + 'plans' => [ + $providerPlanB->getKey() => 'Provider Plan B', + ] + ]); +}); From def9e3c722c2513e4747a88705d6acd547405d14 Mon Sep 17 00:00:00 2001 From: "Ralph J. Smit" <59207045+ralphjsmit@users.noreply.github.com> Date: Thu, 28 Sep 2023 20:50:07 +0200 Subject: [PATCH 2/5] Style --- app/Filament/Resources/PackageResource.php | 30 ++++++------- .../PackageResource/Pages/EditPackage.php | 2 +- .../RelationManagers/UsersRelationManager.php | 4 +- app/Http/Controllers/ServerController.php | 16 +++---- app/Http/Middleware/HasAccessToThisGroup.php | 2 +- app/Models/Package.php | 4 +- app/Models/PackageProvider.php | 2 +- ...632_create_package_provider_plan_table.php | 4 +- routes/web.php | 42 +++++++++---------- .../Http/Controllers/ServerControllerTest.php | 22 +++++----- 10 files changed, 64 insertions(+), 64 deletions(-) diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index a3b6ba0..fbadda2 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -2,18 +2,18 @@ namespace App\Filament\Resources; -use App\Filament\Resources\PackageResource\Pages; -use App\Filament\Resources\PackageResource\RelationManagers; +use Filament\Forms; +use Filament\Tables; use App\Models\Package; use App\Models\Provider; -use App\Models\ProviderPlan; -use Filament\Forms; use Filament\Forms\Form; -use Filament\Notifications\Notification; -use Filament\Resources\Resource; -use Filament\Tables; use Filament\Tables\Table; +use App\Models\ProviderPlan; +use Filament\Resources\Resource; use Illuminate\Support\HtmlString; +use Filament\Notifications\Notification; +use App\Filament\Resources\PackageResource\Pages; +use App\Filament\Resources\PackageResource\RelationManagers; class PackageResource extends Resource { @@ -134,8 +134,8 @@ class PackageResource extends Resource }), Forms\Components\CheckboxList::make("provider_plans") ->label(__('Select plans')) - ->options(fn() => $provider->plans->mapWithKeys(fn(ProviderPlan $providerPlan) => [$providerPlan->id => $providerPlan->label ?? $providerPlan->plan_id])->all()) - ->visible(fn(Forms\Get $get) => $get('select_specific_provider_plans')) + ->options(fn () => $provider->plans->mapWithKeys(fn (ProviderPlan $providerPlan) => [$providerPlan->id => $providerPlan->label ?? $providerPlan->plan_id])->all()) + ->visible(fn (Forms\Get $get) => $get('select_specific_provider_plans')) ->reactive() ->bulkToggleable() ->columns(2) @@ -170,10 +170,10 @@ class PackageResource extends Resource ->color('gray') ->disabled(function (Package $record, Forms\Get $get) { $providers = collect($get('providers')) - ->map(fn(string $id): int => (int)$id) + ->map(fn (string $id): int => (int)$id) ->sort(); - return $record->providers->pluck('id')->map(fn(string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); + return $record->providers->pluck('id')->map(fn (string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); }) ]), Forms\Components\Placeholder::make('save_warning') @@ -183,10 +183,10 @@ class PackageResource extends Resource $package = $livewire->getRecord(); $providers = collect($get('providers')) - ->map(fn(string $id): int => (int)$id) + ->map(fn (string $id): int => (int)$id) ->sort(); - return $package->providers->pluck('id')->map(fn(string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); + return $package->providers->pluck('id')->map(fn (string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); }) ->hiddenLabel(), ]) @@ -217,10 +217,10 @@ class PackageResource extends Resource return "Attached to stripe - {$record->price_monthly} {$record->currency}"; }), Tables\Columns\TextColumn::make('maximum_sites') - ->formatStateUsing(fn(int $state) => $state === 0 ? __('Unlimited') : $state) + ->formatStateUsing(fn (int $state) => $state === 0 ? __('Unlimited') : $state) ->label(__('Maximum sites')), Tables\Columns\TextColumn::make('maximum_servers') - ->formatStateUsing(fn(int $state) => $state === 0 ? __('Unlimited') : $state) + ->formatStateUsing(fn (int $state) => $state === 0 ? __('Unlimited') : $state) ->label(__('Maximum servers')), Tables\Columns\TextColumn::make('users_count') ->counts('users'), diff --git a/app/Filament/Resources/PackageResource/Pages/EditPackage.php b/app/Filament/Resources/PackageResource/Pages/EditPackage.php index 02d35d4..f01c5b0 100644 --- a/app/Filament/Resources/PackageResource/Pages/EditPackage.php +++ b/app/Filament/Resources/PackageResource/Pages/EditPackage.php @@ -2,9 +2,9 @@ namespace App\Filament\Resources\PackageResource\Pages; -use App\Filament\Resources\PackageResource; use Filament\Actions; use Filament\Resources\Pages\EditRecord; +use App\Filament\Resources\PackageResource; class EditPackage extends EditRecord { diff --git a/app/Filament/Resources/PackageResource/RelationManagers/UsersRelationManager.php b/app/Filament/Resources/PackageResource/RelationManagers/UsersRelationManager.php index 7600475..7ef7b8e 100644 --- a/app/Filament/Resources/PackageResource/RelationManagers/UsersRelationManager.php +++ b/app/Filament/Resources/PackageResource/RelationManagers/UsersRelationManager.php @@ -28,10 +28,10 @@ class UsersRelationManager extends RelationManager ...$table->getHeaderActions(), Action::make('add_user') ->label(__('Add user')) - ->form(fn(self $livewire) => [ + ->form(fn (self $livewire) => [ Select::make('user_id') ->label('User') - ->options(User::orderBy('name')->get()->mapWithKeys(fn(User $user) => [$user->id => $user->name])) + ->options(User::orderBy('name')->get()->mapWithKeys(fn (User $user) => [$user->id => $user->name])) ->preload() ->searchable() ->required(), diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 082c041..b6a3537 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -2,17 +2,17 @@ namespace App\Http\Controllers; -use App\Actions\Server\CreateServerAction; -use App\DataTransferObjects\ServerData; -use App\Http\Requests\ServerUpdateRequest; -use App\Http\Resources\ServerResource; -use App\Jobs\Servers\DeleteServer; -use App\Models\ProviderPlan; use App\Models\Server; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Http\RedirectResponse; +use App\Models\ProviderPlan; use Illuminate\Http\Request; +use App\Jobs\Servers\DeleteServer; use Illuminate\Support\Facades\Auth; +use Illuminate\Http\RedirectResponse; +use App\Http\Resources\ServerResource; +use App\DataTransferObjects\ServerData; +use Illuminate\Database\Eloquent\Builder; +use App\Actions\Server\CreateServerAction; +use App\Http\Requests\ServerUpdateRequest; class ServerController extends Controller { diff --git a/app/Http/Middleware/HasAccessToThisGroup.php b/app/Http/Middleware/HasAccessToThisGroup.php index 8b4d06f..d39a4a5 100644 --- a/app/Http/Middleware/HasAccessToThisGroup.php +++ b/app/Http/Middleware/HasAccessToThisGroup.php @@ -3,8 +3,8 @@ namespace App\Http\Middleware; use Closure; -use Illuminate\Http\Request; use Illuminate\Support\Arr; +use Illuminate\Http\Request; class HasAccessToThisGroup { diff --git a/app/Models/Package.php b/app/Models/Package.php index 1a159f6..70d1b75 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -3,10 +3,10 @@ namespace App\Models; use App\Casts\PermissionCast; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Package extends Model { diff --git a/app/Models/PackageProvider.php b/app/Models/PackageProvider.php index e077bf9..fa2a020 100644 --- a/app/Models/PackageProvider.php +++ b/app/Models/PackageProvider.php @@ -2,8 +2,8 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class PackageProvider extends Pivot { diff --git a/database/migrations/2023_09_28_115632_create_package_provider_plan_table.php b/database/migrations/2023_09_28_115632_create_package_provider_plan_table.php index c422155..368db16 100644 --- a/database/migrations/2023_09_28_115632_create_package_provider_plan_table.php +++ b/database/migrations/2023_09_28_115632_create_package_provider_plan_table.php @@ -1,8 +1,8 @@ withPackage(fn(PackageFactory $factory) => $factory->has(Provider::factory()->withRegion()->withPlan()))->create() + $user = User::factory()->withPackage(fn (PackageFactory $factory) => $factory->has(Provider::factory()->withRegion()->withPlan()))->create() ); $provider = Provider::sole(); @@ -74,13 +74,13 @@ it('can create a new server', function () { 'database_type' => 'postgresql', ]); - Mail::assertQueued(AdminServerCreatedEmail::class, fn(AdminServerCreatedEmail $mail) => $mail->to[0]['address'] === $adminUsers[0]->email); - Mail::assertQueued(AdminServerCreatedEmail::class, fn(AdminServerCreatedEmail $mail) => $mail->to[0]['address'] === $adminUsers[1]->email); + Mail::assertQueued(AdminServerCreatedEmail::class, fn (AdminServerCreatedEmail $mail) => $mail->to[0]['address'] === $adminUsers[0]->email); + Mail::assertQueued(AdminServerCreatedEmail::class, fn (AdminServerCreatedEmail $mail) => $mail->to[0]['address'] === $adminUsers[1]->email); }); it('cannot create a server without permissions', function () { actingAs( - $user = User::factory()->withPackage(fn(PackageFactory $factory) => $factory->serverPermissions(['create' => false,])->has(Provider::factory()->withRegion()->withPlan()))->create() + $user = User::factory()->withPackage(fn (PackageFactory $factory) => $factory->serverPermissions(['create' => false,])->has(Provider::factory()->withRegion()->withPlan()))->create() ); expect($user->can('create', Server::class))->toBeFalse(); From 99a49848ca09b912ccaf754a68dbb4d1cec3c93a Mon Sep 17 00:00:00 2001 From: "Ralph J. Smit" <59207045+ralphjsmit@users.noreply.github.com> Date: Thu, 28 Sep 2023 20:55:20 +0200 Subject: [PATCH 3/5] Translations --- app/Filament/Resources/PackageResource.php | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index fbadda2..b1d2056 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -2,18 +2,18 @@ namespace App\Filament\Resources; -use Filament\Forms; -use Filament\Tables; -use App\Models\Package; -use App\Models\Provider; -use Filament\Forms\Form; -use Filament\Tables\Table; -use App\Models\ProviderPlan; -use Filament\Resources\Resource; -use Illuminate\Support\HtmlString; -use Filament\Notifications\Notification; use App\Filament\Resources\PackageResource\Pages; use App\Filament\Resources\PackageResource\RelationManagers; +use App\Models\Package; +use App\Models\Provider; +use App\Models\ProviderPlan; +use Filament\Forms; +use Filament\Forms\Form; +use Filament\Notifications\Notification; +use Filament\Resources\Resource; +use Filament\Tables; +use Filament\Tables\Table; +use Illuminate\Support\HtmlString; class PackageResource extends Resource { @@ -117,7 +117,7 @@ class PackageResource extends Resource ->form(function (Package $record) { return $record->providers->sortBy('name')->map(function (Provider $provider) { return Forms\Components\Section::make($provider->label) - ->description('Select the plans that should be available for this provider on this package.') + ->description(__('Select the plans that should be available for this provider on this package.')) ->icon(ProviderResource::getNavigationIcon()) ->statePath($provider->id) ->schema([ @@ -134,8 +134,8 @@ class PackageResource extends Resource }), Forms\Components\CheckboxList::make("provider_plans") ->label(__('Select plans')) - ->options(fn () => $provider->plans->mapWithKeys(fn (ProviderPlan $providerPlan) => [$providerPlan->id => $providerPlan->label ?? $providerPlan->plan_id])->all()) - ->visible(fn (Forms\Get $get) => $get('select_specific_provider_plans')) + ->options(fn() => $provider->plans->mapWithKeys(fn(ProviderPlan $providerPlan) => [$providerPlan->id => $providerPlan->label ?? $providerPlan->plan_id])->all()) + ->visible(fn(Forms\Get $get) => $get('select_specific_provider_plans')) ->reactive() ->bulkToggleable() ->columns(2) @@ -166,14 +166,14 @@ class PackageResource extends Resource ->success() ->send(); }) - ->modalSubmitActionLabel('Save') + ->modalSubmitActionLabel(__('Save')) ->color('gray') ->disabled(function (Package $record, Forms\Get $get) { $providers = collect($get('providers')) - ->map(fn (string $id): int => (int)$id) + ->map(fn(string $id): int => (int)$id) ->sort(); - return $record->providers->pluck('id')->map(fn (string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); + return $record->providers->pluck('id')->map(fn(string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); }) ]), Forms\Components\Placeholder::make('save_warning') @@ -183,10 +183,10 @@ class PackageResource extends Resource $package = $livewire->getRecord(); $providers = collect($get('providers')) - ->map(fn (string $id): int => (int)$id) + ->map(fn(string $id): int => (int)$id) ->sort(); - return $package->providers->pluck('id')->map(fn (string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); + return $package->providers->pluck('id')->map(fn(string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); }) ->hiddenLabel(), ]) @@ -217,10 +217,10 @@ class PackageResource extends Resource return "Attached to stripe - {$record->price_monthly} {$record->currency}"; }), Tables\Columns\TextColumn::make('maximum_sites') - ->formatStateUsing(fn (int $state) => $state === 0 ? __('Unlimited') : $state) + ->formatStateUsing(fn(int $state) => $state === 0 ? __('Unlimited') : $state) ->label(__('Maximum sites')), Tables\Columns\TextColumn::make('maximum_servers') - ->formatStateUsing(fn (int $state) => $state === 0 ? __('Unlimited') : $state) + ->formatStateUsing(fn(int $state) => $state === 0 ? __('Unlimited') : $state) ->label(__('Maximum servers')), Tables\Columns\TextColumn::make('users_count') ->counts('users'), From 255353763f669f303e1aaaf73ac01541c5866008 Mon Sep 17 00:00:00 2001 From: "Ralph J. Smit" <59207045+ralphjsmit@users.noreply.github.com> Date: Thu, 28 Sep 2023 20:57:58 +0200 Subject: [PATCH 4/5] Add clarifying comment --- app/Filament/Resources/PackageResource.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index b1d2056..8165aa9 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -155,10 +155,14 @@ class PackageResource extends Resource }) ->action(function (Package $record, array $data) { $providerPlanIds = collect($data) + // If `select_specific_provider_plans`, all provider plans are available. It could be that this + // option was deselected, and that we have some left over provider plans in the field that + // is now hidden. We will not include theSE IDs so that they ARE detached automatically. ->where('select_specific_provider_plans', true) ->pluck('provider_plans') ->flatten(); + // Detaches provider plans not specifically selected. $record->providerPlans()->sync($providerPlanIds); Notification::make() From 3aae5068cef2400c593357a11face869f39b4915 Mon Sep 17 00:00:00 2001 From: "Ralph J. Smit" <59207045+ralphjsmit@users.noreply.github.com> Date: Thu, 28 Sep 2023 20:58:54 +0200 Subject: [PATCH 5/5] Simplify --- app/Filament/Resources/PackageResource.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index 8165aa9..058bbaa 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -182,15 +182,12 @@ class PackageResource extends Resource ]), Forms\Components\Placeholder::make('save_warning') ->content(__('You\'ve changed the available server providers. Please save your changes before you can manage the provider plans.')) - ->visible(function (Pages\EditPackage $livewire, Forms\Get $get) { - /** @var Package $package */ - $package = $livewire->getRecord(); - + ->visible(function (Package $record, Forms\Get $get) { $providers = collect($get('providers')) ->map(fn(string $id): int => (int)$id) ->sort(); - return $package->providers->pluck('id')->map(fn(string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); + return $record->providers->pluck('id')->map(fn(string $id): int => (int)$id)->sort()->toArray() !== $providers->all(); }) ->hiddenLabel(), ])