diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index eda8e88..08793de 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -83,6 +83,8 @@ jobs: echo "DEV_FIRST_NAME=Admin" echo "DEV_LAST_NAME=User" echo "DEV_PASSWORD=secret-password" + echo "DEV_REPOSITORIES=" + echo "DEV_PACKAGES=" echo "DEV_SEED_DEVELOPMENT_DATA=true" } >> .env diff --git a/composer.json b/composer.json index 3fa1d37..1be90f8 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ "laravel/horizon": "^5.35", "laravel/octane": "^2.12", "laravel/sanctum": "^4.0", - "livewire/livewire": "^v4.0" + "livewire/livewire": "^v4.0", + "phpseclib/phpseclib": "^3.0" }, "autoload": { "psr-4": { @@ -49,18 +50,9 @@ "Froxlor\\Core\\": "packages/core/src", "Froxlor\\Core\\Database\\Factories\\": "packages/core/database/factories/", "Froxlor\\Core\\Database\\Seeders\\": "packages/core/database/seeders/", - "Froxlor\\Database\\": "packages/database/src", - "Froxlor\\Developer\\": "packages/developer/src", - "Froxlor\\Domain\\": "packages/domain/src", - "Froxlor\\Domain\\Database\\Seeders\\": "packages/domain/database/seeders/", - "Froxlor\\Ftp\\": "packages/ftp/src", - "Froxlor\\Mail\\": "packages/mail/src", - "Froxlor\\Mail\\Database\\Seeders\\": "packages/mail/database/seeders/", "Froxlor\\Packages\\": "packages/packages/src", "Froxlor\\Packages\\Database\\Factories\\": "packages/packages/database/factories/", "Froxlor\\Packages\\Database\\Seeders\\": "packages/packages/database/seeders/", - "Froxlor\\Web\\": "packages/web/src", - "Froxlor\\Web\\Database\\Seeders\\": "packages/web/database/seeders/", "Froxlor\\UI\\": "packages/ui/src" } }, @@ -81,14 +73,8 @@ "replace": { "froxlor/auth": "self.version", "froxlor/core": "self.version", - "froxlor/database": "self.version", - "froxlor/developer": "self.version", - "froxlor/domain": "self.version", - "froxlor/ftp": "self.version", - "froxlor/mail": "self.version", "froxlor/package": "self.version", - "froxlor/ui": "self.version", - "froxlor/web": "self.version" + "froxlor/ui": "self.version" }, "extra": { "froxlor": { @@ -100,17 +86,8 @@ "Froxlor\\Core\\Providers\\EventServiceProvider", "Froxlor\\Core\\Providers\\FroxlorCoreServiceProvider", "Froxlor\\Core\\Providers\\HorizonServiceProvider", - "Froxlor\\Database\\Providers\\FroxlorDatabaseServiceProvider", - "Froxlor\\Developer\\Providers\\FroxlorDeveloperServiceProvider", - "Froxlor\\Domain\\Providers\\EventServiceProvider", - "Froxlor\\Domain\\Providers\\FroxlorDomainServiceProvider", - "Froxlor\\Ftp\\Providers\\FroxlorFtpServiceProvider", - "Froxlor\\Mail\\Providers\\EventServiceProvider", - "Froxlor\\Mail\\Providers\\FroxlorMailServiceProvider", "Froxlor\\Packages\\Providers\\EventServiceProvider", "Froxlor\\Packages\\Providers\\FroxlorPackageServiceProvider", - "Froxlor\\Web\\Providers\\EventServiceProvider", - "Froxlor\\Web\\Providers\\FroxlorWebServiceProvider", "Froxlor\\UI\\Providers\\FroxlorUIServiceProvider" ] } diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index e613e02..c35e7e9 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -1,6 +1,7 @@ services: froxlor: image: hub.froxlor.io/froxlor/froxlor:latest + command: ["php", "artisan", "serve"] privileged: true pid: "host" depends_on: diff --git a/packages/core/composer.json b/packages/core/composer.json index 7cdc3f3..1788bc5 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -25,7 +25,8 @@ "require": { "php": "^8.5", "illuminate/support": "^12.0", - "laravel/sanctum": "^4.0" + "laravel/sanctum": "^4.0", + "phpseclib/phpseclib": "^3.0" }, "autoload": { "psr-4": { diff --git a/packages/core/database/migrations/0001_01_01_000040_create_plans_table.php b/packages/core/database/migrations/0001_01_01_000040_create_plans_table.php index 62eec04..a66177c 100644 --- a/packages/core/database/migrations/0001_01_01_000040_create_plans_table.php +++ b/packages/core/database/migrations/0001_01_01_000040_create_plans_table.php @@ -14,13 +14,12 @@ public function up(): void Schema::create('plans', function (Blueprint $table) { $table->ulid('id')->primary(); $table->ulid('tenant_id')->nullable()->index(); - $table->enum('type', ['tenant', 'environment'])->default('environment'); $table->string('name'); $table->string('description')->nullable(); $table->timestamps(); $table->softDeletes(); - $table->unique(['tenant_id', 'type', 'name']); + $table->unique(['tenant_id', 'name']); }); Schema::table('environments', function (Blueprint $table) { diff --git a/packages/core/database/migrations/0001_01_01_000073_create_tenant_resource_reservations_table.php b/packages/core/database/migrations/0001_01_01_000073_create_tenant_resource_reservations_table.php new file mode 100644 index 0000000..322f266 --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000073_create_tenant_resource_reservations_table.php @@ -0,0 +1,38 @@ +ulid('id')->primary(); + $table->ulid('tenant_id')->index(); + $table->ulid('reserved_for_tenant_id')->index(); + $table->ulid('plan_id')->index(); + $table->string('resource_key')->index(); + $table->string('resource_type')->index(); + $table->bigInteger('limit')->default(0); + $table->timestamps(); + + $table->unique(['tenant_id', 'reserved_for_tenant_id', 'resource_key', 'resource_type'], 'tenant_resource_reservation_unique'); + + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + $table->foreign('reserved_for_tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + $table->foreign('plan_id')->references('id')->on('plans')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_resource_reservations'); + } +}; diff --git a/packages/core/database/seeders/DatabaseSeeder.php b/packages/core/database/seeders/DatabaseSeeder.php index abbf1dd..eea8628 100644 --- a/packages/core/database/seeders/DatabaseSeeder.php +++ b/packages/core/database/seeders/DatabaseSeeder.php @@ -19,12 +19,12 @@ public function run(): void { // call required package seeders $this->call($this->coreSeederClasses()); - Audit::log('The core seeder classes have been seeded.'); + Audit::info('The core seeder classes have been seeded.'); // call development/test fixture seeders if (SeedProfile::includesDevelopmentData()) { $this->call($this->fixtureSeederClasses()); - Audit::log('The ' . SeedProfile::developmentDataLabel() . ' core seeder classes have been seeded.'); + Audit::debug('The ' . SeedProfile::developmentDataLabel() . ' core seeder classes have been seeded.'); } // notify other seeders diff --git a/packages/core/database/seeders/PlansAndResourcesTableSeeder.php b/packages/core/database/seeders/PlansAndResourcesTableSeeder.php index f6aaf5d..affffc7 100644 --- a/packages/core/database/seeders/PlansAndResourcesTableSeeder.php +++ b/packages/core/database/seeders/PlansAndResourcesTableSeeder.php @@ -9,79 +9,200 @@ use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; +use Froxlor\Core\Support\ResourceRegistry; use Illuminate\Database\Seeder; class PlansAndResourcesTableSeeder extends Seeder { - /** - * Seed the baseline resource catalog and default plans. - * - * Resources are package-owned metadata and are therefore part of production seeding. - * The default plans provide useful bootstrap assignments for the root tenant and the - * local/testing fixture graph. + * Seed the baseline resource catalog and global production plans. * - * @return void + * The default plans are split by human-facing use case, but plans themselves are + * not scoped. `resources.type` only defines where actual usage is counted. Limit + * semantics are `0` for no access, `-1` for unlimited, and positive values for + * finite limits. */ public function run(): void { - // id=1 | everything unlimited - self::createPlanWithResources('Unlimited', [ - ['key' => 'tenants', 'name' => 'Tenants', 'type' => 'tenant', 'model_type' => Tenant::class, 'limit' => -1], - ['key' => 'environments', 'name' => 'User environments', 'type' => 'tenant', 'model_type' => Environment::class, 'limit' => -1], - ['key' => 'nodes', 'name' => 'Nodes', 'type' => 'tenant', 'model_type' => Node::class, 'limit' => -1], - ['key' => 'plans', 'name' => 'Usage plans', 'type' => 'tenant', 'model_type' => Plan::class, 'limit' => -1], - ['key' => 'users', 'name' => 'Tenant Users', 'type' => 'tenant', 'model_type' => User::class, 'limit' => -1], - ['key' => 'users', 'name' => 'Environment Users', 'type' => 'environment', 'model_type' => User::class, 'limit' => -1], - ['key' => 'roles', 'name' => 'User roles', 'type' => 'tenant', 'model_type' => Role::class, 'limit' => -1], + self::seedResourceCatalog(); + + $platformUnlimited = self::createTenantPlan('Platform Unlimited', [ + 'tenants' => -1, + 'environments' => -1, + 'nodes' => -1, + 'plans' => -1, + 'users' => -1, + 'roles' => -1, + ]); + self::attachEnvironmentResourceLimits($platformUnlimited, ['users' => -1]); + + $tenantStandard = self::createTenantPlan('Tenant Standard', [ + 'tenants' => 0, + 'environments' => 10, + 'nodes' => 0, + 'plans' => 5, + 'users' => 25, + 'roles' => 10, + ]); + self::attachEnvironmentResourceLimits($tenantStandard, ['users' => 10]); + + $tenantStarter = self::createTenantPlan('Tenant Starter', [ + 'tenants' => 0, + 'environments' => 1, + 'nodes' => 0, + 'plans' => 0, + 'users' => 3, + 'roles' => 3, + ]); + self::attachEnvironmentResourceLimits($tenantStarter, ['users' => 2]); + + self::createEnvironmentPlan('Environment Unlimited', [ + 'users' => -1, ]); - // id=2 | everything 10x allowed - self::createPlanWithResources('Everything 10', [ - ['key' => 'tenants', 'name' => 'Tenants', 'type' => 'tenant', 'model_type' => Tenant::class, 'limit' => 10], - ['key' => 'environments', 'name' => 'User environments', 'type' => 'tenant', 'model_type' => Environment::class, 'limit' => 10], - ['key' => 'nodes', 'name' => 'Nodes', 'type' => 'tenant', 'model_type' => Node::class, 'limit' => 10], - ['key' => 'plans', 'name' => 'Usage plans', 'type' => 'tenant', 'model_type' => Plan::class, 'limit' => 10], - ['key' => 'users', 'name' => 'Tenant Users', 'type' => 'tenant', 'model_type' => User::class, 'limit' => 10], - ['key' => 'users', 'name' => 'Environment Users', 'type' => 'environment', 'model_type' => User::class, 'limit' => 10], - ['key' => 'roles', 'name' => 'User roles', 'type' => 'tenant', 'model_type' => Role::class, 'limit' => 10], + self::createEnvironmentPlan('Environment Standard', [ + 'users' => 10, ]); - // id=3 | only plans and roles - self::createPlanWithResources('Plans and roles', [ - ['key' => 'plans', 'name' => 'Usage plans', 'type' => 'tenant', 'model_type' => Plan::class, 'limit' => -1], - ['key' => 'roles', 'name' => 'User roles', 'type' => 'tenant', 'model_type' => Role::class, 'limit' => 5], - ['key' => 'test-resource', 'name' => 'Some resource later', 'type' => 'environment', 'model_type' => User::class, 'limit' => 10], + self::createEnvironmentPlan('Environment Starter', [ + 'users' => 2, ]); } /** - * Create a plan and attach resource limits to it. + * Seed all core resources once so plans can reuse stable resource definitions. + */ + public static function seedResourceCatalog(): void + { + ResourceRegistry::registerPackageModelsFrom(dirname(__DIR__, 3)); + ResourceRegistry::sync(); + } + + /** + * Create or update a global or tenant-owned plan from tenant-usage resources. + * + * @param array $limits Resource key to limit map. + */ + public static function createTenantPlan(string $name, array $limits, ?string $tenantId = null): Plan + { + return self::createPlanWithResourceLimits($name, $limits, $tenantId, 'tenant'); + } + + /** + * Create or update a global or tenant-owned plan from environment-usage resources. + * + * @param array $limits Resource key to limit map. + */ + public static function createEnvironmentPlan(string $name, array $limits, ?string $tenantId = null): Plan + { + return self::createPlanWithResourceLimits($name, $limits, $tenantId, 'environment'); + } + + /** + * Attach environment-usage resource limits to an existing plan. * - * @param string $string Human readable plan name. - * @param array $resources - * @param string|null $tenant_id Tenant owner for tenant-specific plans, or null for global plans. + * This is used for mixed plans: a tenant plan can carry both tenant-usage and + * environment-usage budgets while `resources.type` still decides where actual + * usage is counted. + * + * @param array $limits Resource key to limit map. */ - public static function createPlanWithResources(string $string, array $resources, ?string $tenant_id = null): Plan + public static function attachEnvironmentResourceLimits(Plan $plan, array $limits): Plan { - /** @var Plan $role */ - $plan = Plan::query()->create([ - 'name' => $string, - 'tenant_id' => !empty($tenant_id) ? $tenant_id : null, + return self::attachResourceLimits($plan, $limits, 'environment'); + } + + /** + * Create a plan and attach resource limits matching the requested resource type. + * + * @param array $limits Resource key to limit map. + */ + private static function createPlanWithResourceLimits(string $name, array $limits, ?string $tenantId = null, ?string $resourceType = null): Plan + { + /** @var Plan $plan */ + $plan = Plan::query()->updateOrCreate([ + 'tenant_id' => $tenantId, + 'name' => $name, + ], [ + 'description' => null, ]); - foreach ($resources as $resource) { - $new_resource = Resource::query()->where('key', $resource['key'])->firstOrCreate([ - 'key' => $resource['key'], - 'name' => $resource['name'], - 'model_type' => $resource['model_type'], - 'type' => $resource['type'] ?? 'environment', - ]); - $plan->resources()->attach($new_resource, [ - 'limit' => $resource['limit'] + foreach ($limits as $key => $limit) { + self::attachResourceLimits($plan, [$key => $limit], $resourceType); + } + + return $plan->refresh(); + } + + /** + * Attach resource limits matching the requested resource type to an existing plan. + * + * @param array $limits Resource key to limit map. + */ + private static function attachResourceLimits(Plan $plan, array $limits, ?string $resourceType = null): Plan + { + $resources = match ($resourceType) { + 'tenant' => self::tenantResources(), + 'environment' => self::environmentResources(), + default => array_merge(self::tenantResources(), self::environmentResources()), + }; + + foreach ($limits as $key => $limit) { + if (!isset($resources[$key])) { + throw new \InvalidArgumentException('Unknown resource key "' . $key . '" for plan "' . $plan->name . '".'); + } + + $plan->resources()->syncWithoutDetaching([ + self::resource($resources[$key])->id => ['limit' => $limit], ]); } - return $plan; + return $plan->refresh(); + } + + /** + * Return tenant-scope core resource definitions. + * + * @return array + */ + private static function tenantResources(): array + { + return [ + 'tenants' => ['key' => 'tenants', 'name' => 'Tenants', 'type' => 'tenant', 'model_type' => Tenant::class], + 'environments' => ['key' => 'environments', 'name' => 'Environments', 'type' => 'tenant', 'model_type' => Environment::class], + 'nodes' => ['key' => 'nodes', 'name' => 'Nodes', 'type' => 'tenant', 'model_type' => Node::class], + 'plans' => ['key' => 'plans', 'name' => 'Plans', 'type' => 'tenant', 'model_type' => Plan::class], + 'users' => ['key' => 'users', 'name' => 'Tenant users', 'type' => 'tenant', 'model_type' => User::class], + 'roles' => ['key' => 'roles', 'name' => 'Roles', 'type' => 'tenant', 'model_type' => Role::class], + ]; + } + + /** + * Return environment-scope core resource definitions. + * + * @return array + */ + private static function environmentResources(): array + { + return [ + 'users' => ['key' => 'users', 'name' => 'Environment users', 'type' => 'environment', 'model_type' => User::class], + ]; + } + + /** + * Create or update one resource definition. + * + * @param array{key: string, name: string, type: string, model_type: class-string} $resource + */ + private static function resource(array $resource): Resource + { + /** @var Resource $model */ + $model = Resource::query()->where([ + 'key' => $resource['key'], + 'model_type' => $resource['model_type'], + 'type' => $resource['type'], + ])->firstOrFail(); + + return $model; } } diff --git a/packages/core/database/seeders/SettingsTableSeeder.php b/packages/core/database/seeders/SettingsTableSeeder.php index c208382..788561f 100644 --- a/packages/core/database/seeders/SettingsTableSeeder.php +++ b/packages/core/database/seeders/SettingsTableSeeder.php @@ -14,6 +14,7 @@ class SettingsTableSeeder extends Seeder */ protected array $settings = [ ['category' => 'auditlog', 'key' => 'enabled', 'value' => true, 'default_value' => true, 'type' => 'boolean'], + ['category' => 'auditlog', 'key' => 'severity', 'value' => 5, 'default_value' => 5, 'type' => 'integer', 'properties' => ['min' => 0, 'max' => 7]], ['category' => 'api', 'key' => 'pagination_limit', 'value' => 15, 'default_value' => 15, 'type' => 'integer'], ]; diff --git a/packages/core/database/seeders/Testing/DatabaseSeeder.php b/packages/core/database/seeders/Testing/DatabaseSeeder.php index 881206c..e8e05a2 100644 --- a/packages/core/database/seeders/Testing/DatabaseSeeder.php +++ b/packages/core/database/seeders/Testing/DatabaseSeeder.php @@ -2,6 +2,7 @@ namespace Froxlor\Core\Database\Seeders\Testing; +use Froxlor\Core\Support\Setting; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -15,7 +16,10 @@ class DatabaseSeeder extends Seeder */ public function run(): void { + Setting::set('auditlog.severity', 7, 'integer', 5); + $this->call([ + PlansAndResourcesTableSeeder::class, TenantAndUsersTableSeeder::class, TenantAndEnvironmentsTableSeeder::class, TenantUsagesTableSeeder::class, diff --git a/packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php b/packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php new file mode 100644 index 0000000..e2ace02 --- /dev/null +++ b/packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php @@ -0,0 +1,89 @@ + -1, + 'environments' => -1, + 'nodes' => -1, + 'plans' => -1, + 'users' => -1, + 'roles' => -1, + ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantUnlimited, ['users' => -1]); + + $testTenantLimited = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Limited', [ + 'tenants' => 2, + 'environments' => 2, + 'nodes' => 2, + 'plans' => 2, + 'users' => 2, + 'roles' => 2, + ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantLimited, ['users' => 2]); + + $testTenantMinimal = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Minimal', [ + 'tenants' => 0, + 'environments' => 1, + 'nodes' => 0, + 'plans' => 0, + 'users' => 1, + 'roles' => 1, + ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantMinimal, ['users' => 1]); + + $testTenantDelegationParent = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Delegation Parent', [ + 'tenants' => 0, + 'environments' => 5, + 'nodes' => 1, + 'plans' => 3, + 'users' => 5, + 'roles' => 5, + ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantDelegationParent, ['users' => 5]); + + $testTenantDelegationChild = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Delegation Child', [ + 'tenants' => 0, + 'environments' => 2, + 'nodes' => 0, + 'plans' => 1, + 'users' => 2, + 'roles' => 2, + ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantDelegationChild, ['users' => 2]); + + BasePlansAndResourcesTableSeeder::createEnvironmentPlan('Test Environment Unlimited', [ + 'users' => -1, + ]); + + BasePlansAndResourcesTableSeeder::createEnvironmentPlan('Test Environment Limited', [ + 'users' => 2, + ]); + + BasePlansAndResourcesTableSeeder::createEnvironmentPlan('Test Environment Minimal', [ + 'users' => 1, + ]); + + BasePlansAndResourcesTableSeeder::createEnvironmentPlan('Test Environment Delegation Parent', [ + 'users' => 5, + ]); + + BasePlansAndResourcesTableSeeder::createEnvironmentPlan('Test Environment Delegation Child', [ + 'users' => 2, + ]); + } +} diff --git a/packages/core/database/seeders/Testing/TenantAndEnvironmentsTableSeeder.php b/packages/core/database/seeders/Testing/TenantAndEnvironmentsTableSeeder.php index 40f3bdf..712415d 100644 --- a/packages/core/database/seeders/Testing/TenantAndEnvironmentsTableSeeder.php +++ b/packages/core/database/seeders/Testing/TenantAndEnvironmentsTableSeeder.php @@ -27,7 +27,7 @@ public function run(): void $env1 = $user->environments()->create([ 'name' => 'Development Environment', 'tenant_id' => $user->tenants[0]->id, - 'plan_id' => Plan::query()->where('name', 'Unlimited')->first()->id // Unlimited plan + 'plan_id' => Plan::query()->where('name', 'Test Environment Unlimited')->first()->id ], [ 'role_id' => Role::query()->where('name', 'Super-Admin')->first()->id // Super-Admin role for the users on this environment ]); @@ -38,6 +38,7 @@ public function run(): void $env2 = $user2->environments()->create([ 'name' => 'Kunden Environment', 'tenant_id' => $user2->tenants[0]->id, + 'plan_id' => Plan::query()->where('name', 'Test Environment Limited')->first()->id, ], [ 'role_id' => Role::query()->where('name', 'Super-Admin')->first()->id // Super-Admin role for the users on this environment ]); @@ -47,7 +48,7 @@ public function run(): void $env3 = $user3->environments()->create([ 'name' => 'Reseller->User Environment', 'tenant_id' => $user3->tenants[0]->id, - 'plan_id' => Plan::query()->where('name', 'Plans and roles')->first()->id // "Plans and roles" plan + 'plan_id' => Plan::query()->where('name', 'Test Environment Minimal')->first()->id ], [ 'role_id' => Role::query()->where('name', 'Reseller')->first()->id // Reseller role for the users on this environment ]); diff --git a/packages/core/database/seeders/Testing/TenantAndUsersTableSeeder.php b/packages/core/database/seeders/Testing/TenantAndUsersTableSeeder.php index c5dc6b5..13f029b 100644 --- a/packages/core/database/seeders/Testing/TenantAndUsersTableSeeder.php +++ b/packages/core/database/seeders/Testing/TenantAndUsersTableSeeder.php @@ -48,7 +48,7 @@ public function run(): void * 1st Level Tenant */ $tenant2 = Tenant::query()->create([ - 'plan_id' => Plan::query()->where('name', 'Everything 10')->first()->id, // Everything 10 + 'plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->first()->id, 'name' => 'First customer', 'parent_tenant_id' => $tenant->id, ]); @@ -73,7 +73,7 @@ public function run(): void */ $tenant3 = Tenant::query()->create([ 'parent_tenant_id' => $tenant2->id, - 'plan_id' => Plan::query()->where('name', 'Unlimited')->first()->id, + 'plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->first()->id, 'name' => 'Kunde #2', 'description' => 'Another Tenant' ]); @@ -92,8 +92,9 @@ public function run(): void // add to tenant $user3->tenants()->attach($tenant3, [ 'role_id' => Role::query()->where('name', 'Super-Admin')->first()->id, // Super-Admin role for the users on this tenant - 'plan_id' => Plan::query()->where('name', 'Everything 10')->first()->id // Everything 10 + 'plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->first()->id ]); + } } diff --git a/packages/core/database/seeders/Testing/TenantUsagesTableSeeder.php b/packages/core/database/seeders/Testing/TenantUsagesTableSeeder.php index 591b8d4..2ee23e3 100644 --- a/packages/core/database/seeders/Testing/TenantUsagesTableSeeder.php +++ b/packages/core/database/seeders/Testing/TenantUsagesTableSeeder.php @@ -30,7 +30,7 @@ public function run(): void $user1 = User::query()->where('email', config('dev.email'))->first(); // user #1 $user2 = User::query()->where('email', 'dev2@froxlor.org')->first(); // user #2 - $planUnlimited = Plan::query()->where('name', 'Unlimited')->first(); + $planUnlimited = Plan::query()->where('name', 'Test Tenant Unlimited')->first(); Resource::addUsage($tenant, $tenant2, $user1); Resource::addUsage($tenant, $planUnlimited, $user1); diff --git a/packages/core/routes/api.php b/packages/core/routes/api.php index 7fdde32..4b1a210 100644 --- a/packages/core/routes/api.php +++ b/packages/core/routes/api.php @@ -23,22 +23,22 @@ Route::apiResource('tenants.environments', Api\Tenant\EnvironmentController::class); Route::apiResource('tenants.environments.audit-log', Api\Tenant\Environment\AuditLogController::class)->only(['index']); Route::apiResource('tenants.environments.users', Api\Tenant\Environment\UserController::class); - Route::apiResource('tenants.environments.plans', Api\Tenant\Environment\PlansController::class); Route::apiResource('tenants.users', Api\Tenant\UserController::class); Route::apiResource('tenants.plans', Api\Tenant\PlanController::class); + Route::apiResource('tenants.plans.resources', Api\Tenant\Plan\PlanResourceController::class)->only(['index', 'store', 'destroy']); Route::apiResource('tenants.roles', Api\Tenant\RoleController::class); Route::apiResource('tenants.roles.permissions', Api\Tenant\Role\RolePermissionController::class)->only(['index', 'store', 'destroy']); + Route::get('plans/resources', [Api\ResourceController::class, 'index'])->name('plans.resources.available'); Route::apiResource('plans', Api\PlanController::class); + Route::apiResource('plans.resources', Api\Plan\PlanResourceController::class)->only(['index', 'store', 'destroy']); + Route::apiResource('plans.users', Api\Plan\UserController::class)->only(['index']); Route::apiResource('roles/permissions', Api\PermissionController::class)->only(['index'])->names([ 'index' => 'roles.permissions.available', ]); Route::apiResource('roles', Api\RoleController::class); Route::apiResource('roles.permissions', Api\Role\RolePermissionController::class)->only(['index', 'store', 'destroy']); Route::apiResource('roles.users', Api\Role\UserController::class)->only(['index']); - - Route::apiResource('resources', Api\ResourceController::class); - // Route::get('user', [Api\UserController::class, 'showCurrent'])->name('users.show-current'); // Route::put('user', [Api\UserController::class, 'updateCurrent'])->name('users.update-current'); // Route::apiResource('users', Api\UserController::class); diff --git a/packages/core/src/Console/Commands/ListPlans.php b/packages/core/src/Console/Commands/ListPlans.php index 54daed3..414adc9 100644 --- a/packages/core/src/Console/Commands/ListPlans.php +++ b/packages/core/src/Console/Commands/ListPlans.php @@ -60,10 +60,9 @@ private function showPlans(): void ->orderBy('name') ->get(); - $this->table(['ID', 'Name', 'Type', 'Tenant ID', 'Resources'], $plans->map(fn(Plan $plan) => [ + $this->table(['ID', 'Name', 'Tenant ID', 'Resources'], $plans->map(fn(Plan $plan) => [ $plan->id, $plan->name, - $plan->type, $plan->tenant_id ?? 'global', $plan->resources_count, ])); @@ -144,7 +143,6 @@ private function showPlan(string $identifier): int } $this->info($plan->name . ' (' . $plan->id . ')'); - $this->line('Type: ' . $plan->type); $this->line('Tenant: ' . ($plan->tenant_id ?? 'global')); $this->newLine(); diff --git a/packages/core/src/Events/Node/NodeExploreUpdate.php b/packages/core/src/Events/Node/NodeExploreUpdate.php index e148c82..f84d7f2 100644 --- a/packages/core/src/Events/Node/NodeExploreUpdate.php +++ b/packages/core/src/Events/Node/NodeExploreUpdate.php @@ -15,7 +15,7 @@ class NodeExploreUpdate public function __construct(public Node $node) { // - Audit::log('node "' . $node->name . '" exploration updated', $node->tenant, null, [ + Audit::debug('node "' . $node->name . '" exploration updated', $node->tenant, null, [ 'node_id' => $node->id, ]); } diff --git a/packages/core/src/Events/Node/NodeExplored.php b/packages/core/src/Events/Node/NodeExplored.php index 26d97fa..cad07b2 100644 --- a/packages/core/src/Events/Node/NodeExplored.php +++ b/packages/core/src/Events/Node/NodeExplored.php @@ -15,7 +15,7 @@ class NodeExplored public function __construct(public Node $node) { // - Audit::log('node "' . $node->name . '" explored', $node->tenant, null, [ + Audit::debug('node "' . $node->name . '" explored', $node->tenant, null, [ 'node_id' => $node->id, ]); } diff --git a/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php new file mode 100644 index 0000000..eb26691 --- /dev/null +++ b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php @@ -0,0 +1,103 @@ +query('ids_only', false)) { + return response()->json([ + 'data' => $plan->resources()->pluck('resources.id'), + ]); + } + + $assignedResources = $plan->resources() + ->select(['resources.id']) + ->get() + ->mapWithKeys(fn(Resource $resource) => [ + $resource->id => (int)$resource->pivot->limit, + ]); + + $resources = Resource::query() + ->orderBy('type') + ->orderBy('key') + ->get() + ->map(function (Resource $resource) use ($assignedResources) { + $resource->assigned = $assignedResources->has($resource->id); + $resource->limit = $assignedResources->get($resource->id, 0); + + return $resource; + }); + + return JsonResource::collection($resources); + } + + /** + * Assign or update a resource limit on a global plan. + */ + public function store(Request $request, Plan $plan) + { + Gate::authorize('resourceCreate', $plan); + + $data = $request->validate([ + 'resource_id' => 'required|string|ulid|exists:resources,id', + 'limit' => 'required|integer|min:-1', + ]); + + $resource = Resource::query()->findOrFail($data['resource_id']); + PlanAssignments::updatePlanResourceLimit($plan, $resource, (int)$data['limit']); + + Audit::info('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $plan->tenant, context: [ + 'plan_id' => $plan->id, + 'resource_id' => $resource->id, + 'resource_key' => $resource->key, + 'limit' => (int)$data['limit'], + ]); + event(new ResourceUpdated($plan, [])); + + return Response::jsonResourceCollection($plan->resources()); + } + + /** + * Remove a resource from a global plan. + */ + public function destroy(Plan $plan, Resource $resource) + { + Gate::authorize('resourceDelete', [$plan, $resource]); + + if (!$plan->resources()->where('resources.id', $resource->id)->exists()) { + throw ValidationException::withMessages([ + 'resource_id' => 'The selected resource is not assigned to this plan.', + ]); + } + + PlanAssignments::removePlanResource($plan, $resource); + + Audit::info('resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', $plan->tenant, context: [ + 'plan_id' => $plan->id, + 'resource_id' => $resource->id, + 'resource_key' => $resource->key, + ]); + event(new ResourceUpdated($plan, [])); + + return Response::jsonResourceCollection($plan->resources()); + } +} diff --git a/packages/core/src/Http/Controllers/Api/Plan/UserController.php b/packages/core/src/Http/Controllers/Api/Plan/UserController.php new file mode 100644 index 0000000..88e8f48 --- /dev/null +++ b/packages/core/src/Http/Controllers/Api/Plan/UserController.php @@ -0,0 +1,140 @@ +json([ + 'data' => [ + ...$this->tenantAssignments($plan), + ...$this->environmentAssignments($plan), + ...$this->tenantUserAssignments($plan), + ...$this->environmentUserAssignments($plan), + ], + ]); + } + + private function tenantAssignments(Plan $plan): array + { + return Tenant::query() + ->where('plan_id', $plan->id) + ->orderBy('name') + ->get() + ->map(fn(Tenant $tenant) => [ + 'type' => 'tenant', + 'tenant_id' => $tenant->id, + 'tenant_name' => $tenant->name, + 'environment_id' => null, + 'environment_name' => null, + 'user_id' => null, + 'user_name' => null, + 'user_email' => null, + ]) + ->all(); + } + + private function environmentAssignments(Plan $plan): array + { + return Environment::query() + ->with('tenant') + ->where('plan_id', $plan->id) + ->orderBy('name') + ->get() + ->map(fn(Environment $environment) => [ + 'type' => 'environment', + 'tenant_id' => $environment->tenant_id, + 'tenant_name' => $environment->tenant?->name, + 'environment_id' => $environment->id, + 'environment_name' => $environment->name, + 'user_id' => null, + 'user_name' => null, + 'user_email' => null, + ]) + ->all(); + } + + private function tenantUserAssignments(Plan $plan): array + { + return DB::table('tenant_user') + ->join('tenants', 'tenants.id', '=', 'tenant_user.tenant_id') + ->join('users', 'users.id', '=', 'tenant_user.user_id') + ->select([ + 'tenant_user.tenant_id', + 'tenant_user.user_id', + 'tenants.name as tenant_name', + 'users.first_name as user_first_name', + 'users.last_name as user_last_name', + 'users.email as user_email', + ]) + ->where('tenant_user.plan_id', $plan->id) + ->orderBy('tenants.name') + ->orderBy('users.last_name') + ->orderBy('users.first_name') + ->get() + ->map(fn(object $assignment) => [ + 'type' => 'tenant_user', + 'tenant_id' => $assignment->tenant_id, + 'tenant_name' => $assignment->tenant_name, + 'environment_id' => null, + 'environment_name' => null, + 'user_id' => $assignment->user_id, + 'user_name' => trim($assignment->user_first_name . ' ' . $assignment->user_last_name), + 'user_email' => $assignment->user_email, + ]) + ->all(); + } + + private function environmentUserAssignments(Plan $plan): array + { + return DB::table('environment_user') + ->join('environments', 'environments.id', '=', 'environment_user.environment_id') + ->join('tenants', 'tenants.id', '=', 'environments.tenant_id') + ->join('users', 'users.id', '=', 'environment_user.user_id') + ->select([ + 'environments.tenant_id', + 'environment_user.environment_id', + 'environment_user.user_id', + 'tenants.name as tenant_name', + 'environments.name as environment_name', + 'users.first_name as user_first_name', + 'users.last_name as user_last_name', + 'users.email as user_email', + ]) + ->where('environment_user.plan_id', $plan->id) + ->orderBy('tenants.name') + ->orderBy('environments.name') + ->orderBy('users.last_name') + ->orderBy('users.first_name') + ->get() + ->map(fn(object $assignment) => [ + 'type' => 'environment_user', + 'tenant_id' => $assignment->tenant_id, + 'tenant_name' => $assignment->tenant_name, + 'environment_id' => $assignment->environment_id, + 'environment_name' => $assignment->environment_name, + 'user_id' => $assignment->user_id, + 'user_name' => trim($assignment->user_first_name . ' ' . $assignment->user_last_name), + 'user_email' => $assignment->user_email, + ]) + ->all(); + } +} diff --git a/packages/core/src/Http/Controllers/Api/PlanController.php b/packages/core/src/Http/Controllers/Api/PlanController.php index 81caf5f..7d9b047 100644 --- a/packages/core/src/Http/Controllers/Api/PlanController.php +++ b/packages/core/src/Http/Controllers/Api/PlanController.php @@ -9,6 +9,7 @@ use Froxlor\Core\Http\Requests\StorePlanRequest; use Froxlor\Core\Http\Requests\UpdatePlanRequest; use Froxlor\Core\Models\Plan; +use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\Response; use Illuminate\Support\Facades\Gate; @@ -73,6 +74,7 @@ public function update(UpdatePlanRequest $request, Plan $plan) public function destroy(Plan $plan) { Gate::authorize('delete', $plan); + PlanAssignments::ensureNotAssigned($plan); $plan->delete(); event(new ResourceDeleted($plan, [])); diff --git a/packages/core/src/Http/Controllers/Api/ResourceController.php b/packages/core/src/Http/Controllers/Api/ResourceController.php index b9cc80c..57b9ea2 100644 --- a/packages/core/src/Http/Controllers/Api/ResourceController.php +++ b/packages/core/src/Http/Controllers/Api/ResourceController.php @@ -3,57 +3,20 @@ namespace Froxlor\Core\Http\Controllers\Api; use Froxlor\Core\Http\Controllers\Controller; -use Froxlor\Core\Http\Requests\StoreResourceRequest; -use Froxlor\Core\Http\Requests\UpdateResourceRequest; +use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Resource; use Froxlor\Core\Support\Response; +use Illuminate\Support\Facades\Gate; class ResourceController extends Controller { /** - * Display a listing of the resource. + * Display the package-provided resources available for plan editing. */ public function index() { - // - return Response::jsonResourceCollection(Resource::query()); - } - - /** - * Store a newly created resource in storage. - */ - public function store(StoreResourceRequest $request) - { - $resource = Resource::query()->create($request->validated()); - - return Response::jsonResource($resource->refresh()); - } - - /** - * Display the specified resource. - */ - public function show(Resource $resource) - { - return Response::jsonResource($resource); - } - - /** - * Update the specified resource in storage. - */ - public function update(UpdateResourceRequest $request, Resource $resource) - { - $resource->update($request->validated()); - - return Response::jsonResource($resource->refresh()); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Resource $resource) - { - $resource->delete(); + Gate::authorize('availableResourcesViewAny', Plan::class); - return response()->noContent(); + return Response::jsonResourceCollection(Resource::query()->orderBy('type')->orderBy('key')); } } diff --git a/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php b/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php index 8cfb3b4..5ff8c80 100644 --- a/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php +++ b/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php @@ -70,7 +70,7 @@ public function store(Request $request, Role $role) $permission->id => ['inheritable' => $data['inheritable'] ?? false], ]); - Audit::log('permission "' . $permission->key . '" assigned to role "' . $role->name . '"', $role->tenant, context: [ + Audit::info('permission "' . $permission->key . '" assigned to role "' . $role->name . '"', $role->tenant, context: [ 'role_id' => $role->id, 'permission_id' => $permission->id, 'permission_key' => $permission->key, @@ -97,7 +97,7 @@ public function destroy(Request $request, Role $role, Permission $permission) $role->permissions()->detach($permission); - Audit::log('permission "' . $permission->key . '" removed from role "' . $role->name . '"', $role->tenant, context: [ + Audit::info('permission "' . $permission->key . '" removed from role "' . $role->name . '"', $role->tenant, context: [ 'role_id' => $role->id, 'permission_id' => $permission->id, 'permission_key' => $permission->key, diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Environment/PlansController.php b/packages/core/src/Http/Controllers/Api/Tenant/Environment/PlansController.php deleted file mode 100644 index 375963c..0000000 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/PlansController.php +++ /dev/null @@ -1,88 +0,0 @@ -where('tenant_id', $tenant->id)->where('type', 'environment')); - } - - /** - * Store a newly created resource in storage. - */ - public function store(StoreEnvironmentPlanRequest $request, Tenant $tenant, Environment $environment) - { - Gate::authorize('tenantEnvCreate', [Plan::class, $tenant, $environment]); - - // get validated data only for ourselves - $planData = $request->validatedResource(); - // fixed values - $planData['tenant_id'] = $tenant->id; - $planData['type'] = 'environment'; - // create resource - $plan = Plan::query()->create($planData); - // build up validated data for others - $eventData = $this->validatedEventData($request); - // throw event that resource was created and append validated data - event(new ResourceCreated($plan, $eventData)); - - // return resource - return Response::jsonResource($plan->refresh()); - } - - /** - * Display the specified resource. - */ - public function show(Request $request, Tenant $tenant, Environment $environment, Plan $plan) - { - Gate::authorize('tenantEnvView', [$plan, $tenant, $environment]); - - return Response::jsonResource($plan); - } - - /** - * Update the specified resource in storage. - */ - public function update(UpdatePlanRequest $request, Tenant $tenant, Environment $environment, Plan $plan) - { - Gate::authorize('tenantEnvUpdate', [$plan, $tenant, $environment]); - - $plan->update($request->validated()); - event(new ResourceUpdated($plan, $this->validatedEventData($request))); - - return Response::jsonResource($plan->refresh()); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Request $request, Tenant $tenant, Environment $environment, Plan $plan) - { - Gate::authorize('tenantEnvDelete', [$plan, $tenant, $environment]); - - $plan->delete(); - event(new ResourceDeleted($plan, [])); - - return response()->noContent(); - } -} diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php index 11840f7..4990d5c 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php @@ -7,11 +7,12 @@ use Froxlor\Core\Events\Api\ResourceUpdated; use Froxlor\Core\Http\Controllers\Controller; use Froxlor\Core\Http\Requests\Tenant\Environment\StoreEnvironmentUserRequest; -use Froxlor\Core\Http\Requests\UpdateUserRequest; +use Froxlor\Core\Http\Requests\Tenant\Environment\UpdateEnvironmentUserRequest; use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; use Froxlor\Core\Support\Audit; +use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\RoleAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; @@ -47,6 +48,8 @@ public function store(StoreEnvironmentUserRequest $request, Tenant $tenant, Envi RoleAssignments::ensureAssignable($request->user(), $tenant_role, 'tenant_role', $tenant); RoleAssignments::ensureAssignable($request->user(), $env_role, 'environment_role', $tenant, $environment); + PlanAssignments::ensureAssignableToTenantUser($tenant_plan, $tenant, 'tenant_plan'); + PlanAssignments::ensureAssignableToEnvironmentUser($env_plan, $tenant, $environment); // create resource $user = User::query()->create($userData); @@ -58,7 +61,7 @@ public function store(StoreEnvironmentUserRequest $request, Tenant $tenant, Envi // throw event that resource was created and append validated data event(new ResourceCreated($user, $eventData)); - Audit::log('user "' . $user->email . '" created', $tenant, $environment); + Audit::notice('user "' . $user->email . '" created', $tenant, $environment); // return resource return Response::jsonResource($user->refresh()); } @@ -78,11 +81,59 @@ public function show(Request $request, Tenant $tenant, Environment $environment, /** * Update the specified resource in storage. */ - public function update(UpdateUserRequest $request, Tenant $tenant, Environment $environment, User $user) + public function update(UpdateEnvironmentUserRequest $request, Tenant $tenant, Environment $environment, User $user) { Gate::authorize('tenantEnvUpdate', [$user, $tenant, $environment]); - $user->update($request->validated()); + $userData = $request->validated(); + $tenantRoleId = $this->getNonModelRequestData('tenant_role', $userData); + $tenantPlanProvided = $request->has('tenant_plan'); + $tenantPlanId = $this->getNonModelRequestData('tenant_plan', $userData); + $environmentRoleId = $this->getNonModelRequestData('environment_role', $userData); + $environmentPlanProvided = $request->has('environment_plan'); + $environmentPlanId = $this->getNonModelRequestData('environment_plan', $userData); + + if (!empty($tenantRoleId)) { + RoleAssignments::ensureAssignable($request->user(), $tenantRoleId, 'tenant_role', $tenant); + } + if (!empty($environmentRoleId)) { + RoleAssignments::ensureAssignable($request->user(), $environmentRoleId, 'environment_role', $tenant, $environment); + } + if ($tenantPlanProvided) { + PlanAssignments::ensureAssignableToTenantUser($tenantPlanId, $tenant, 'tenant_plan', $user->id); + } + if ($environmentPlanProvided) { + PlanAssignments::ensureAssignableToEnvironmentUser($environmentPlanId, $tenant, $environment, 'environment_plan', $user->id); + } + + $user->update($userData); + + $tenantPivotData = []; + if (!empty($tenantRoleId)) { + $tenantPivotData['role_id'] = $tenantRoleId; + } + if ($tenantPlanProvided) { + $tenantPivotData['plan_id'] = $tenantPlanId; + } + if ($tenantPivotData !== []) { + $user->tenants()->syncWithoutDetaching([ + $tenant->id => $tenantPivotData, + ]); + } + + $environmentPivotData = []; + if (!empty($environmentRoleId)) { + $environmentPivotData['role_id'] = $environmentRoleId; + } + if ($environmentPlanProvided) { + $environmentPivotData['plan_id'] = $environmentPlanId; + } + if ($environmentPivotData !== []) { + $user->environments()->syncWithoutDetaching([ + $environment->id => $environmentPivotData, + ]); + } + event(new ResourceUpdated($user, $this->validatedEventData($request))); return Response::jsonResource($user->refresh()); diff --git a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php index 8c8515f..0443127 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php @@ -11,8 +11,8 @@ use Froxlor\Core\Jobs\Environment\CreateEnvironment; use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Node; -use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; @@ -43,7 +43,7 @@ public function store(StoreEnvironmentRequest $request, Tenant $tenant) $envData['tenant_id'] = $tenant->id; // non-model values $node_id = $this->getNonModelRequestData('node_id', $envData); - $this->ensurePlanCanBeUsedForEnvironment($envData['plan_id'] ?? null, $tenant); + PlanAssignments::ensureAssignableToEnvironment($envData['plan_id'] ?? null, $tenant); // create resource $env = Environment::query()->create($envData); // build up validated data for others @@ -79,7 +79,9 @@ public function update(UpdateEnvironmentRequest $request, Tenant $tenant, Enviro $envData = $request->validated(); $nodeId = $this->getNonModelRequestData('node_id', $envData); - $this->ensurePlanCanBeUsedForEnvironment($envData['plan_id'] ?? null, $tenant); + if (array_key_exists('plan_id', $envData)) { + PlanAssignments::ensureAssignableToEnvironment($envData['plan_id'], $tenant, 'plan_id', $environment); + } $environment->update($envData); event(new ResourceUpdated($environment, $this->validatedEventData($request))); @@ -126,27 +128,4 @@ private function nodeForTenant(string $nodeId, Tenant $tenant): Node return $node; } - /** - * Ensure the selected plan is an environment plan available to the tenant. - * - * Environment plans may be global or tenant-owned. Plans owned by another - * tenant, or plans for another plan type, must not be assignable even when - * their ULID is known. - * - * @throws ValidationException - */ - private function ensurePlanCanBeUsedForEnvironment(?string $planId, Tenant $tenant): void - { - if ($planId === null) { - return; - } - - $plan = Plan::query()->findOrFail($planId); - - if (!$plan->isEnvironmentPlan() || !$plan->isAvailableForTenant($tenant)) { - throw ValidationException::withMessages([ - 'plan_id' => trans('validation.exists', ['attribute' => 'plan_id']), - ]); - } - } } diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php new file mode 100644 index 0000000..04420b5 --- /dev/null +++ b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php @@ -0,0 +1,104 @@ +query('ids_only', false)) { + return response()->json([ + 'data' => $plan->resources()->pluck('resources.id'), + ]); + } + + $assignedResources = $plan->resources() + ->select(['resources.id']) + ->get() + ->mapWithKeys(fn(Resource $resource) => [ + $resource->id => (int)$resource->pivot->limit, + ]); + + $resources = Resource::query() + ->orderBy('type') + ->orderBy('key') + ->get() + ->map(function (Resource $resource) use ($assignedResources) { + $resource->assigned = $assignedResources->has($resource->id); + $resource->limit = $assignedResources->get($resource->id, 0); + + return $resource; + }); + + return JsonResource::collection($resources); + } + + /** + * Assign or update a resource limit on a tenant-owned plan. + */ + public function store(Request $request, Tenant $tenant, Plan $plan) + { + Gate::authorize('tenantResourceCreate', [$plan, $tenant]); + + $data = $request->validate([ + 'resource_id' => 'required|string|ulid|exists:resources,id', + 'limit' => 'required|integer|min:-1', + ]); + + $resource = Resource::query()->findOrFail($data['resource_id']); + PlanAssignments::updatePlanResourceLimit($plan, $resource, (int)$data['limit'], $tenant); + + Audit::info('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $tenant, context: [ + 'plan_id' => $plan->id, + 'resource_id' => $resource->id, + 'resource_key' => $resource->key, + 'limit' => (int)$data['limit'], + ]); + event(new ResourceUpdated($plan, [])); + + return Response::jsonResourceCollection($plan->resources()); + } + + /** + * Remove a resource from a tenant-owned plan. + */ + public function destroy(Tenant $tenant, Plan $plan, Resource $resource) + { + Gate::authorize('tenantResourceDelete', [$plan, $tenant, $resource]); + + if (!$plan->resources()->where('resources.id', $resource->id)->exists()) { + throw ValidationException::withMessages([ + 'resource_id' => 'The selected resource is not assigned to this plan.', + ]); + } + + PlanAssignments::removePlanResource($plan, $resource); + + Audit::info('resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', $tenant, context: [ + 'plan_id' => $plan->id, + 'resource_id' => $resource->id, + 'resource_key' => $resource->key, + ]); + event(new ResourceUpdated($plan, [])); + + return Response::jsonResourceCollection($plan->resources()); + } +} diff --git a/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php b/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php index 263a569..1da1f48 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php @@ -10,6 +10,7 @@ use Froxlor\Core\Http\Requests\UpdatePlanRequest; use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; @@ -23,16 +24,8 @@ public function index(Request $request, Tenant $tenant) { Gate::authorize('tenantViewAny', [Plan::class, $tenant]); - $type = $request->query('type', ''); $tenantPlans = Plan::query()->where('tenant_id', '=', $tenant->id); - if (!empty($type) && in_array($type, ['environment', 'tenant'])) { - $tenantPlans->where('type', '=', $type) - ->with('resources', function ($query) use ($type) { - return $query->where('resources.type', '=', $type); - }); - } else { - $tenantPlans->with('resources'); - } + $tenantPlans->with('resources'); return Response::jsonResourceCollection($tenantPlans); } @@ -95,6 +88,7 @@ public function update(UpdatePlanRequest $request, Tenant $tenant, Plan $plan) public function destroy(Request $request, Tenant $tenant, Plan $plan) { Gate::authorize('tenantDelete', [$plan, $tenant]); + PlanAssignments::ensureNotAssigned($plan); $plan->delete(); event(new ResourceDeleted($plan, [])); diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Role/RolePermissionController.php b/packages/core/src/Http/Controllers/Api/Tenant/Role/RolePermissionController.php index 1aee7e1..69c7131 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Role/RolePermissionController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Role/RolePermissionController.php @@ -67,7 +67,7 @@ public function store(Request $request, Tenant $tenant, Role $role) $permission->id => ['inheritable' => $data['inheritable'] ?? false], ]); - Audit::log('permission "' . $permission->key . '" assigned to role "' . $role->name . '"', $tenant, context: [ + Audit::notice('permission "' . $permission->key . '" assigned to role "' . $role->name . '"', $tenant, context: [ 'role_id' => $role->id, 'permission_id' => $permission->id, 'permission_key' => $permission->key, @@ -94,7 +94,7 @@ public function destroy(Request $request, Tenant $tenant, Role $role, Permission $role->permissions()->detach($permission); - Audit::log('permission "' . $permission->key . '" removed from role "' . $role->name . '"', $tenant, context: [ + Audit::info('permission "' . $permission->key . '" removed from role "' . $role->name . '"', $tenant, context: [ 'role_id' => $role->id, 'permission_id' => $permission->id, 'permission_key' => $permission->key, diff --git a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php index 8c9cada..ce76f7a 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php @@ -13,11 +13,11 @@ use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; use Froxlor\Core\Support\Audit; +use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\RoleAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; -use Illuminate\Validation\ValidationException; class UserController extends Controller { @@ -48,7 +48,7 @@ public function store(StoreTenantUserRequest $request, Tenant $tenant) ?? $this->getNonModelRequestData('plan', $userData); RoleAssignments::ensureAssignable($request->user(), $role, 'role_id', $tenant); - $this->ensurePlanCanBeAssignedToTenant($plan, $tenant); + PlanAssignments::ensureAssignableToTenantUser($plan, $tenant); // create resource $user = User::query()->create($userData); @@ -58,7 +58,7 @@ public function store(StoreTenantUserRequest $request, Tenant $tenant) // throw event that resource was created and append validated data event(new ResourceCreated($user, $eventData)); - Audit::log('user "' . $user->email . '" created', $tenant); + Audit::notice('user "' . $user->email . '" created', $tenant); // return resource return Response::jsonResource($user->refresh()); } @@ -110,7 +110,9 @@ public function update(UpdateUserRequest $request, Tenant $tenant, User $user) ?? $this->getNonModelRequestData('plan', $userData); RoleAssignments::ensureAssignable($request->user(), $roleId, 'role_id', $tenant); - $this->ensurePlanCanBeAssignedToTenant($planId, $tenant); + if ($planProvided) { + PlanAssignments::ensureAssignableToTenantUser($planId, $tenant, 'plan_id', $user->id); + } $user->update($userData); @@ -145,18 +147,4 @@ public function destroy(Request $request, Tenant $tenant, User $user) return response()->json(['message' => 'User removed from environment successfully'], 200); } - private function ensurePlanCanBeAssignedToTenant(?string $planId, Tenant $tenant): void - { - if (empty($planId)) { - return; - } - - $plan = Plan::query()->findOrFail($planId); - - if ($plan->tenant_id !== null && $plan->tenant_id !== $tenant->id) { - throw ValidationException::withMessages([ - 'plan_id' => 'The selected plan is not available for this tenant.', - ]); - } - } } diff --git a/packages/core/src/Http/Controllers/Api/TenantController.php b/packages/core/src/Http/Controllers/Api/TenantController.php index 1f49830..a7dbc54 100644 --- a/packages/core/src/Http/Controllers/Api/TenantController.php +++ b/packages/core/src/Http/Controllers/Api/TenantController.php @@ -9,9 +9,12 @@ use Froxlor\Core\Http\Requests\StoreTenantRequest; use Froxlor\Core\Http\Requests\UpdateTenantRequest; use Froxlor\Core\Models\Node; +use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; class TenantController extends Controller @@ -36,20 +39,31 @@ public function store(StoreTenantRequest $request) $tenantData = $request->validatedResource(); $nodes = $tenantData['nodes'] ?? []; unset($tenantData['nodes']); - $parentTenant = Tenant::query()->find($tenantData['parent_tenant_id']); + $parentTenant = Tenant::query()->findOrFail($tenantData['parent_tenant_id']); Gate::authorize('create', [Tenant::class, $parentTenant]); - // create resource - $tenant = Tenant::query()->create($tenantData); - foreach ($nodes as $nodeData) { - $node = Node::query()->findOrFail($nodeData['id']); - abort_unless($node->isInheritableByTenant($parentTenant), 422, 'The selected node cannot be inherited by this tenant.'); + $plan = Plan::query()->findOrFail($tenantData['plan_id']); - $tenant->nodes()->syncWithoutDetaching([ - $node->id => ['inheritable' => (bool)($nodeData['inheritable'] ?? false)], - ]); - } + $tenant = DB::transaction(function () use ($tenantData, $nodes, $parentTenant, $plan) { + PlanAssignments::lockTenantBudget($parentTenant); + PlanAssignments::ensureAssignableToChildTenant($plan, $parentTenant); + + // create resource + $tenant = Tenant::query()->create($tenantData); + PlanAssignments::syncTenantReservations($parentTenant, $tenant, $plan); + + foreach ($nodes as $nodeData) { + $node = Node::query()->findOrFail($nodeData['id']); + abort_unless($node->isInheritableByTenant($parentTenant), 422, 'The selected node cannot be inherited by this tenant.'); + + $tenant->nodes()->syncWithoutDetaching([ + $node->id => ['inheritable' => (bool)($nodeData['inheritable'] ?? false)], + ]); + } + + return $tenant; + }); // build up validated data for others $eventData = $this->validatedEventData($request); // throw event that resource was created and append validated data @@ -76,7 +90,36 @@ public function update(UpdateTenantRequest $request, Tenant $tenant) { Gate::authorize('update', $tenant); - $tenant->update($request->validated()); + $tenantData = $request->validated(); + $parentTenant = array_key_exists('parent_tenant_id', $tenantData) + ? Tenant::query()->find($tenantData['parent_tenant_id']) + : $tenant->parentTenant; + $plan = array_key_exists('plan_id', $tenantData) + ? Plan::query()->findOrFail($tenantData['plan_id']) + : $tenant->plan; + $oldParentTenant = $tenant->parentTenant; + + DB::transaction(function () use ($tenant, $tenantData, $oldParentTenant, $parentTenant, $plan): void { + if ($oldParentTenant !== null && ($parentTenant === null || $oldParentTenant->id !== $parentTenant->id)) { + PlanAssignments::lockTenantBudget($oldParentTenant); + } + + if ($parentTenant !== null) { + PlanAssignments::lockTenantBudget($parentTenant); + PlanAssignments::ensureAssignableToChildTenant($plan, $parentTenant, $tenant); + } + + if ($oldParentTenant !== null) { + PlanAssignments::removeTenantReservations($oldParentTenant, $tenant); + } + + $tenant->update($tenantData); + + if ($parentTenant !== null) { + PlanAssignments::syncTenantReservations($parentTenant, $tenant->refresh(), $plan); + } + + }); event(new ResourceUpdated($tenant, $this->validatedEventData($request))); return Response::jsonResource($tenant->refresh()); diff --git a/packages/core/src/Http/Controllers/Api/UserController.php b/packages/core/src/Http/Controllers/Api/UserController.php index 182a673..7b9fe11 100644 --- a/packages/core/src/Http/Controllers/Api/UserController.php +++ b/packages/core/src/Http/Controllers/Api/UserController.php @@ -12,6 +12,7 @@ use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; +use Froxlor\Core\Support\PlanAssignments; use Froxlor\Core\Support\RoleAssignments; use Froxlor\Core\Support\Response; use Illuminate\Http\Request; @@ -60,6 +61,7 @@ public function store(StoreUserRequest $request) ?? $this->getNonModelRequestData('plan', $userData); RoleAssignments::ensureAssignable($request->user(), $role, 'role_id', $targetTenant); + PlanAssignments::ensureAssignableToTenantUser($plan, $targetTenant); // create resource $user = User::query()->create($userData); @@ -117,9 +119,14 @@ public function update(UpdateUserRequest $request, User $user) $planId = $this->getNonModelRequestData('plan_id', $userData) ?? $this->getNonModelRequestData('plan', $userData); - if ($tenantId && $roleId) { + if ($tenantId) { $targetTenant = Tenant::query()->findOrFail($tenantId); - RoleAssignments::ensureAssignable($request->user(), $roleId, 'role_id', $targetTenant); + if ($roleId) { + RoleAssignments::ensureAssignable($request->user(), $roleId, 'role_id', $targetTenant); + } + if ($planProvided) { + PlanAssignments::ensureAssignableToTenantUser($planId, $targetTenant, 'plan_id', $user->id); + } } $user->update($userData); diff --git a/packages/core/src/Http/Requests/StorePlanRequest.php b/packages/core/src/Http/Requests/StorePlanRequest.php index ddddd84..2f75fe0 100644 --- a/packages/core/src/Http/Requests/StorePlanRequest.php +++ b/packages/core/src/Http/Requests/StorePlanRequest.php @@ -4,7 +4,6 @@ use Froxlor\Core\Http\Requests\Abstract\FroxlorFormRequest; use Froxlor\Core\Models\Plan; -use Illuminate\Validation\Rule; class StorePlanRequest extends FroxlorFormRequest { @@ -25,7 +24,6 @@ public function rules(): array { return [ 'name' => 'required|string', - 'type' => ['string', Rule::in(['tenant', 'environment'])], 'description' => 'string|nullable', ]; } diff --git a/packages/core/src/Http/Requests/Tenant/Environment/StoreEnvironmentPlanRequest.php b/packages/core/src/Http/Requests/Tenant/Environment/StoreEnvironmentPlanRequest.php deleted file mode 100644 index 6649ef4..0000000 --- a/packages/core/src/Http/Requests/Tenant/Environment/StoreEnvironmentPlanRequest.php +++ /dev/null @@ -1,36 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'name' => 'required|string', - 'description' => 'string|nullable', - ]; - } - - public function withEventRules(): array - { - return [Plan::class, 'environmentStore']; - } -} diff --git a/packages/core/src/Http/Requests/Tenant/Environment/UpdateEnvironmentUserRequest.php b/packages/core/src/Http/Requests/Tenant/Environment/UpdateEnvironmentUserRequest.php new file mode 100644 index 0000000..276a96e --- /dev/null +++ b/packages/core/src/Http/Requests/Tenant/Environment/UpdateEnvironmentUserRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return array_merge(parent::rules(), [ + 'tenant_role' => ['sometimes', 'string', 'ulid', 'exists:roles,id'], + 'tenant_plan' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:plans,id'], + 'environment_role' => ['sometimes', 'string', 'ulid', 'exists:roles,id'], + 'environment_plan' => ['sometimes', 'nullable', 'string', 'ulid', 'exists:plans,id'], + ]); + } +} diff --git a/packages/core/src/Http/Requests/UpdatePlanRequest.php b/packages/core/src/Http/Requests/UpdatePlanRequest.php index 8264fe3..5a5005c 100644 --- a/packages/core/src/Http/Requests/UpdatePlanRequest.php +++ b/packages/core/src/Http/Requests/UpdatePlanRequest.php @@ -23,9 +23,8 @@ public function rules(): array { return [ 'name' => 'sometimes|string', - 'type' => 'sometimes|string|in:tenant,environment', 'description' => 'sometimes|nullable|string', - 'tenant_id' => 'sometimes|nullable|string|ulid|exists:tenants,id', + 'tenant_id' => 'prohibited', ]; } } diff --git a/packages/core/src/Jobs/Environment/CreateEnvironment.php b/packages/core/src/Jobs/Environment/CreateEnvironment.php index c24af1c..3aa91a3 100644 --- a/packages/core/src/Jobs/Environment/CreateEnvironment.php +++ b/packages/core/src/Jobs/Environment/CreateEnvironment.php @@ -91,7 +91,7 @@ public function handle(): void ]); event(new EnvironmentCreated($environment)); - Audit::log('environment "' . $environment->name . '" created on node "' . $node->name . '"', $environment->tenant, $environment, [ + Audit::notice('environment "' . $environment->name . '" created on node "' . $node->name . '"', $environment->tenant, $environment, [ 'node_id' => $node->id, 'unix_name' => $unixName, 'guid' => $guid, diff --git a/packages/core/src/Jobs/Environment/DeleteEnvironment.php b/packages/core/src/Jobs/Environment/DeleteEnvironment.php index ae81240..ff4b016 100644 --- a/packages/core/src/Jobs/Environment/DeleteEnvironment.php +++ b/packages/core/src/Jobs/Environment/DeleteEnvironment.php @@ -50,7 +50,7 @@ public function handle(): void $this->environment->nodes()->detach($node->id); - Audit::log('environment "' . $this->environment->name . '" deleted from node "' . $node->name . '"', $this->environment->tenant, $this->environment, [ + Audit::notice('environment "' . $this->environment->name . '" deleted from node "' . $node->name . '"', $this->environment->tenant, $this->environment, [ 'node_id' => $node->id, 'unix_name' => $unixName, 'guid' => $guid, diff --git a/packages/core/src/Models/Environment.php b/packages/core/src/Models/Environment.php index 99b7c70..585aba1 100644 --- a/packages/core/src/Models/Environment.php +++ b/packages/core/src/Models/Environment.php @@ -6,6 +6,7 @@ use Froxlor\Core\Observers\EnvironmentObserver; use Froxlor\Core\Services\Traits\HasPermissions; use Froxlor\Core\Services\Traits\IsResource; +use Froxlor\Core\Services\Traits\IsTenantResource; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -35,7 +36,7 @@ #[ObservedBy(EnvironmentObserver::class)] class Environment extends Model { - use HasUlids, IsResource, HasPermissions; + use HasUlids, IsResource, IsTenantResource, HasPermissions; protected $guarded = []; diff --git a/packages/core/src/Models/EnvironmentUser.php b/packages/core/src/Models/EnvironmentUser.php index ae84ab6..cce42d9 100644 --- a/packages/core/src/Models/EnvironmentUser.php +++ b/packages/core/src/Models/EnvironmentUser.php @@ -63,7 +63,10 @@ public function hasResourceAvailable(string $resource): bool } } /** @var Resource $resource_to_check */ - $resource_to_check = $plan->resources()->where('key', $resource)->first(); + $resource_to_check = $plan->resources() + ->where('resources.key', $resource) + ->where('resources.type', 'environment') + ->first(); if (empty($resource_to_check)) { // don't have this resource assigned to plan at all return false; diff --git a/packages/core/src/Models/Node.php b/packages/core/src/Models/Node.php index 2a28bf1..37770da 100644 --- a/packages/core/src/Models/Node.php +++ b/packages/core/src/Models/Node.php @@ -10,6 +10,7 @@ use Froxlor\Core\Services\Traits\HasPermissions; use Froxlor\Core\Services\Traits\HasSettings; use Froxlor\Core\Services\Traits\IsResource; +use Froxlor\Core\Services\Traits\IsTenantResource; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -46,7 +47,7 @@ #[ObservedBy(NodeObserver::class)] class Node extends Model { - use HasUlids, HasAdapter, HasPermissions, HasSettings, IsResource; + use HasUlids, HasAdapter, HasPermissions, HasSettings, IsResource, IsTenantResource; protected $guarded = []; diff --git a/packages/core/src/Models/Plan.php b/packages/core/src/Models/Plan.php index 24404f0..836c64a 100644 --- a/packages/core/src/Models/Plan.php +++ b/packages/core/src/Models/Plan.php @@ -4,6 +4,7 @@ use Froxlor\Core\Services\Traits\HasPermissions; use Froxlor\Core\Services\Traits\IsResource; +use Froxlor\Core\Services\Traits\IsTenantResource; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; @@ -16,7 +17,6 @@ /** * @property string $id * @property string|null $tenant_id - * @property string $type * @property string $name * @property string|null $description * @property Carbon $created_at @@ -28,7 +28,9 @@ */ class Plan extends Model { - use HasUlids, IsResource, HasPermissions; + use HasUlids, IsResource, IsTenantResource, HasPermissions { + HasPermissions::getAllPermissions as protected getBasePermissions; + } protected $guarded = []; @@ -50,11 +52,23 @@ public function resources(): BelongsToMany } /** - * Limit the query to plans usable for environments. + * Return plan CRUD permissions plus resource-assignment permissions. + * + * Plan resources are managed as a separate API surface, mirroring role + * permissions, because changing a plan's resource limits is a security-sensitive + * quota change rather than a simple plan metadata update. */ - public function scopeEnvironment(Builder $query): Builder + public static function getAllPermissions(): array { - return $query->where('type', 'environment'); + return [ + ...self::getBasePermissions(), + ['key' => 'plans.resources.*', 'name' => 'Manage plan resources'], + ['key' => 'plans.resources.index', 'name' => 'View plan resources'], + ['key' => 'plans.resources.store', 'name' => 'Assign plan resources'], + ['key' => 'plans.resources.destroy', 'name' => 'Remove plan resources'], + ['key' => 'plans.users.*', 'name' => 'Manage plan users'], + ['key' => 'plans.users.index', 'name' => 'View plan users'], + ]; } /** @@ -71,14 +85,6 @@ public function scopeAvailableForTenant(Builder $query, Tenant $tenant): Builder }); } - /** - * Check whether this plan can be assigned to an environment. - */ - public function isEnvironmentPlan(): bool - { - return $this->type === 'environment'; - } - /** * Check whether this plan is available in the given tenant context. */ diff --git a/packages/core/src/Models/Resource.php b/packages/core/src/Models/Resource.php index 35948a9..9e62b86 100644 --- a/packages/core/src/Models/Resource.php +++ b/packages/core/src/Models/Resource.php @@ -37,7 +37,7 @@ public function plans(): BelongsToMany public function limit(): Attribute { return Attribute::make( - get: fn() => $this->pivot->limit + get: fn($value) => $this->pivot?->limit ?? $value ); } } diff --git a/packages/core/src/Models/Role.php b/packages/core/src/Models/Role.php index 7521ca1..6827238 100644 --- a/packages/core/src/Models/Role.php +++ b/packages/core/src/Models/Role.php @@ -4,6 +4,7 @@ use Froxlor\Core\Services\Traits\HasPermissions; use Froxlor\Core\Services\Traits\IsResource; +use Froxlor\Core\Services\Traits\IsTenantResource; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; @@ -26,7 +27,7 @@ */ class Role extends Model { - use HasUlids, IsResource, HasPermissions; + use HasUlids, IsResource, IsTenantResource, HasPermissions; protected $guarded = []; diff --git a/packages/core/src/Models/Tenant.php b/packages/core/src/Models/Tenant.php index 834712a..0db57a2 100644 --- a/packages/core/src/Models/Tenant.php +++ b/packages/core/src/Models/Tenant.php @@ -5,6 +5,7 @@ use Froxlor\Core\Exceptions\UnknownTenantUserException; use Froxlor\Core\Services\Traits\HasPermissions; use Froxlor\Core\Services\Traits\IsResource; +use Froxlor\Core\Services\Traits\IsTenantResource; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -30,13 +31,15 @@ * @property Collection $roles * @property Collection $users * @property Collection $tenantUsages + * @property Collection $resourceReservations + * @property Collection $receivedResourceReservations * @property Collection $subTenants * @property Collection $allSubTenants * @property Tenant|null $parentTenant */ class Tenant extends Model { - use HasUlids, IsResource, HasPermissions; + use HasUlids, IsResource, IsTenantResource, HasPermissions; public $guarded = []; @@ -86,6 +89,22 @@ public function tenantUsages(): HasMany return $this->hasMany(TenantUsage::class); } + /** + * Quota this tenant has delegated to direct child tenants. + */ + public function resourceReservations(): HasMany + { + return $this->hasMany(TenantResourceReservation::class); + } + + /** + * Quota reserved by this tenant's parent for this tenant. + */ + public function receivedResourceReservations(): HasMany + { + return $this->hasMany(TenantResourceReservation::class, 'reserved_for_tenant_id'); + } + public function tenantUsageList(): Attribute { return Attribute::make( @@ -308,6 +327,10 @@ public static function getAllPermissions(): array ['key' => $basePermKey . '.plans.store', 'name' => 'Create plans in ' . $basePermKey], ['key' => $basePermKey . '.plans.update', 'name' => 'Update plans in ' . $basePermKey], ['key' => $basePermKey . '.plans.destroy', 'name' => 'Delete plans in ' . $basePermKey], + ['key' => $basePermKey . '.plans.resources.*', 'name' => 'Manage plan resources in ' . $basePermKey], + ['key' => $basePermKey . '.plans.resources.index', 'name' => 'View plan resources in ' . $basePermKey], + ['key' => $basePermKey . '.plans.resources.store', 'name' => 'Assign plan resources in ' . $basePermKey], + ['key' => $basePermKey . '.plans.resources.destroy', 'name' => 'Remove plan resources in ' . $basePermKey], // tenants users ['key' => $basePermKey . '.users.*', 'name' => 'Manage ' . $basePermKey . ' users'], ['key' => $basePermKey . '.users.index', 'name' => 'View ' . $basePermKey . ' users'], diff --git a/packages/core/src/Models/TenantResourceReservation.php b/packages/core/src/Models/TenantResourceReservation.php new file mode 100644 index 0000000..01f8e96 --- /dev/null +++ b/packages/core/src/Models/TenantResourceReservation.php @@ -0,0 +1,50 @@ +belongsTo(Tenant::class); + } + + public function reservedForTenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'reserved_for_tenant_id'); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } +} diff --git a/packages/core/src/Models/TenantUser.php b/packages/core/src/Models/TenantUser.php index 2d38f7a..a97ddf9 100644 --- a/packages/core/src/Models/TenantUser.php +++ b/packages/core/src/Models/TenantUser.php @@ -77,7 +77,10 @@ public function hasResourceAvailable(string $resource): bool $plan = $this->tenant->plan; } /** @var Resource $resource_to_check */ - $resource_to_check = $plan->resources()->where('key', $resource)->first(); + $resource_to_check = $plan->resources() + ->where('resources.key', $resource) + ->where('resources.type', 'tenant') + ->first(); if (empty($resource_to_check)) { // don't have this resource assigned to plan at all return false; diff --git a/packages/core/src/Models/User.php b/packages/core/src/Models/User.php index 32cbcca..1a42bd8 100644 --- a/packages/core/src/Models/User.php +++ b/packages/core/src/Models/User.php @@ -5,6 +5,8 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Froxlor\Core\Services\Traits\HasPermissions; use Froxlor\Core\Services\Traits\IsResource; +use Froxlor\Core\Services\Traits\IsEnvironmentResource; +use Froxlor\Core\Services\Traits\IsTenantResource; use Froxlor\Core\Services\Traits\CanDelegatePermissions; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -41,7 +43,7 @@ class User extends Authenticatable { /** @use HasFactory<\Froxlor\Core\Database\Factories\UserFactory> */ - use HasFactory, HasUlids, Notifiable, HasApiTokens, IsResource, HasPermissions, CanDelegatePermissions, SoftDeletes; + use HasFactory, HasUlids, Notifiable, HasApiTokens, IsResource, IsTenantResource, IsEnvironmentResource, HasPermissions, CanDelegatePermissions, SoftDeletes; protected $guarded = []; diff --git a/packages/core/src/Observers/EnvironmentObserver.php b/packages/core/src/Observers/EnvironmentObserver.php index a59ab4b..dbd9c58 100644 --- a/packages/core/src/Observers/EnvironmentObserver.php +++ b/packages/core/src/Observers/EnvironmentObserver.php @@ -72,7 +72,7 @@ public function created(Environment $environment): void Resource::addUsage($environment->tenant, $environment, auth()->user()); } - Audit::log('environment "' . $environment->name . '" created', $environment->tenant, $environment, [ + Audit::notice('environment "' . $environment->name . '" created', $environment->tenant, $environment, [ 'plan_id' => $environment->plan_id, ]); } @@ -82,7 +82,7 @@ public function created(Environment $environment): void */ public function updated(Environment $environment): void { - Audit::log('environment "' . $environment->name . '" updated', $environment->tenant, $environment, [ + Audit::info('environment "' . $environment->name . '" updated', $environment->tenant, $environment, [ 'plan_id' => $environment->plan_id, ]); } @@ -110,7 +110,7 @@ public function deleted(Environment $environment): void ->where('resource_id', $environment->id) ->delete(); - Audit::log('environment "' . $environment->name . '" deleted', $environment->tenant, $environment, [ + Audit::info('environment "' . $environment->name . '" deleted', $environment->tenant, $environment, [ 'plan_id' => $environment->plan_id, ]); } diff --git a/packages/core/src/Observers/NodeObserver.php b/packages/core/src/Observers/NodeObserver.php index 9d78a62..d4fffac 100644 --- a/packages/core/src/Observers/NodeObserver.php +++ b/packages/core/src/Observers/NodeObserver.php @@ -55,7 +55,7 @@ public function created(Node $node): void } } - Audit::log('node "' . $node->name . '" created', $node->tenant, null, [ + Audit::info('node "' . $node->name . '" created', $node->tenant, null, [ 'node_id' => $node->id, ]); } @@ -65,7 +65,7 @@ public function created(Node $node): void */ public function updated(Node $node): void { - Audit::log('node "' . $node->name . '" updated', $node->tenant, null, [ + Audit::info('node "' . $node->name . '" updated', $node->tenant, null, [ 'node_id' => $node->id, ]); } @@ -92,7 +92,7 @@ public function deleted(Node $node): void ->delete(); } - Audit::log('node "' . $node->name . '" deleted', $node->tenant, null, [ + Audit::info('node "' . $node->name . '" deleted', $node->tenant, null, [ 'node_id' => $node->id, ]); } diff --git a/packages/core/src/Policies/PlanPolicy.php b/packages/core/src/Policies/PlanPolicy.php index 6211d67..4d4e2ed 100644 --- a/packages/core/src/Policies/PlanPolicy.php +++ b/packages/core/src/Policies/PlanPolicy.php @@ -2,7 +2,6 @@ namespace Froxlor\Core\Policies; -use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; @@ -49,6 +48,35 @@ public function delete(User $user, Plan $plan): bool return $user->hasPermission('plans.destroy'); } + public function resourceViewAny(User $user, Plan $plan): bool + { + return $plan->tenant_id === null + && $user->hasPermission('plans.resources.index'); + } + + public function availableResourcesViewAny(User $user): bool + { + return $user->hasPermission('plans.resources.index'); + } + + public function resourceCreate(User $user, Plan $plan): bool + { + return $plan->tenant_id === null + && $user->hasPermission('plans.resources.store'); + } + + public function resourceDelete(User $user, Plan $plan): bool + { + return $plan->tenant_id === null + && $user->hasPermission('plans.resources.destroy'); + } + + public function usersViewAny(User $user, Plan $plan): bool + { + return $plan->tenant_id === null + && $user->hasPermission('plans.users.index'); + } + public function tenantViewAny(User $user, Tenant $tenant): bool { return $this->hasScopedPermission($user, 'tenants.plans.index', $tenant); @@ -86,40 +114,31 @@ public function tenantDelete(User $user, Plan $plan, Tenant $tenant): bool return $this->hasScopedPermission($user, 'tenants.plans.destroy', $tenant); } - public function tenantEnvViewAny(User $user, Tenant $tenant, Environment $environment): bool - { - return $this->hasScopedPermission($user, 'tenants.environments.plans.index', $tenant, $environment); - } - - public function tenantEnvView(User $user, Plan $plan, Tenant $tenant, Environment $environment): bool + public function tenantResourceViewAny(User $user, Plan $plan, Tenant $tenant): bool { - if ($plan->tenant_id !== $tenant->id || $plan->type !== 'environment') { + if ($plan->tenant_id !== $tenant->id) { return false; } - return $this->hasScopedPermission($user, 'tenants.environments.plans.index', $tenant, $environment); + return $this->hasScopedPermission($user, 'tenants.plans.resources.index', $tenant); } - public function tenantEnvCreate(User $user, Tenant $tenant, Environment $environment): bool + public function tenantResourceCreate(User $user, Plan $plan, Tenant $tenant): bool { - return $this->hasScopedPermission($user, 'tenants.environments.plans.store', $tenant, $environment); - } - - public function tenantEnvUpdate(User $user, Plan $plan, Tenant $tenant, Environment $environment): bool - { - if ($plan->tenant_id !== $tenant->id || $plan->type !== 'environment') { + if ($plan->tenant_id !== $tenant->id) { return false; } - return $this->hasScopedPermission($user, 'tenants.environments.plans.update', $tenant, $environment); + return $this->hasScopedPermission($user, 'tenants.plans.resources.store', $tenant); } - public function tenantEnvDelete(User $user, Plan $plan, Tenant $tenant, Environment $environment): bool + public function tenantResourceDelete(User $user, Plan $plan, Tenant $tenant): bool { - if ($plan->tenant_id !== $tenant->id || $plan->type !== 'environment') { + if ($plan->tenant_id !== $tenant->id) { return false; } - return $this->hasScopedPermission($user, 'tenants.environments.plans.destroy', $tenant, $environment); + return $this->hasScopedPermission($user, 'tenants.plans.resources.destroy', $tenant); } + } diff --git a/packages/core/src/Providers/EventServiceProvider.php b/packages/core/src/Providers/EventServiceProvider.php index c1178b7..974d4f4 100644 --- a/packages/core/src/Providers/EventServiceProvider.php +++ b/packages/core/src/Providers/EventServiceProvider.php @@ -28,7 +28,7 @@ public function boot(): void Event::listen('eloquent.*', function (string $eventName, array $data) { [$event, $type] = array_map('trim', explode(': ', $eventName)); if (in_array($event, ['eloquent.updated', 'eloquent.created', 'eloquent.deleted', 'eloquent.restored'])) { - Audit::log( + Audit::info( collect(Arr::dot([ 'event' => $event, 'type' => $type, diff --git a/packages/core/src/Providers/FroxlorCoreServiceProvider.php b/packages/core/src/Providers/FroxlorCoreServiceProvider.php index 5d7fc37..650c88f 100644 --- a/packages/core/src/Providers/FroxlorCoreServiceProvider.php +++ b/packages/core/src/Providers/FroxlorCoreServiceProvider.php @@ -23,6 +23,7 @@ use Froxlor\Core\Support\FroxlorVersion; use Froxlor\Core\Support\PackageServiceProvider; use Froxlor\Core\Support\PermissionRegistry; +use Froxlor\Core\Support\ResourceRegistry; use Froxlor\UI\Pushable\SidebarLink; use Froxlor\UI\Pushable\SidebarTenantLink; use Froxlor\UI\Support\UI; @@ -62,6 +63,7 @@ public function boot(): void // Permissions PermissionRegistry::registerPackageModelsFrom(dirname(__DIR__, 3)); + ResourceRegistry::registerPackageModelsFrom(dirname(__DIR__, 3)); // Blade components Blade::componentNamespace('Froxlor\\Core\\Views\\Components', 'froxlor-core'); diff --git a/packages/core/src/Resources/Overview/Schemas/OverviewSchema.php b/packages/core/src/Resources/Overview/Schemas/OverviewSchema.php index 17cad49..337acca 100644 --- a/packages/core/src/Resources/Overview/Schemas/OverviewSchema.php +++ b/packages/core/src/Resources/Overview/Schemas/OverviewSchema.php @@ -21,7 +21,7 @@ public static function schema(): array { return [ Schemas\Schema::make('overview.stats') - ->gridCols('grid-cols-1 md:grid-cols-2 xl:grid-cols-3') + ->gridCols('grid-cols-1 md:grid-cols-2') ->components([ InfoWidget::make('users_count') ->title(trans('froxlor-core::generic.users')) diff --git a/packages/core/src/Resources/Plans/PlanResource.php b/packages/core/src/Resources/Plans/PlanResource.php index 300db25..faadf4a 100644 --- a/packages/core/src/Resources/Plans/PlanResource.php +++ b/packages/core/src/Resources/Plans/PlanResource.php @@ -41,7 +41,7 @@ public function create(): Schema return Schema::make() ->title(trans('froxlor-core::generic.edit_resource')) ->description(trans('froxlor-core::generic.edit_resource')) - ->push(route('api.nodes.store')) + ->push(route('api.plans.store')) ->intendedRoute('resources.plans.index') ->components(PlanForm::schema()) ->actions(PlanForm::actions()); diff --git a/packages/core/src/Resources/Plans/Relations/Resources/Tables/ResourceTable.php b/packages/core/src/Resources/Plans/Relations/Resources/Schemas/ResourceTable.php similarity index 89% rename from packages/core/src/Resources/Plans/Relations/Resources/Tables/ResourceTable.php rename to packages/core/src/Resources/Plans/Relations/Resources/Schemas/ResourceTable.php index e612363..2535247 100644 --- a/packages/core/src/Resources/Plans/Relations/Resources/Tables/ResourceTable.php +++ b/packages/core/src/Resources/Plans/Relations/Resources/Schemas/ResourceTable.php @@ -1,6 +1,6 @@ title(trans('froxlor-core::generic.title')) ->components([ - Forms\Components\Select::make('adapter') - ->label(trans('froxlor-core::generic.adapter')) - ->options(fn() => array_map(fn($adapter) => trans($adapter::$name), Node::adapters())) - ->default(Local::class) - ->required(), - Forms\Components\TextInput::make('name') ->label(trans('froxlor-core::generic.name')) - ->default('Node ' . str()->random(4)) + ->default('Plan ' . str()->random(4)) ->required(), Forms\Components\TextInput::make('description') ->label(trans('froxlor-core::generic.description')), - ]), - - Schemas\Components\Section::make('machine') - ->title(trans('froxlor-core::generic.title')) - ->components([ - Forms\Components\TextInput::make('hostname') - ->label(trans('froxlor-core::generic.hostname')) - ->required(), - - Forms\Components\TextInput::make('username') - ->label(trans('froxlor-core::generic.username')) - ->required(), - - Forms\Components\TextInput::make('password') - ->label(trans('froxlor-core::generic.password')), - - Forms\Components\TextInput::make('ssh_key') - ->label(trans('froxlor-core::generic.ssh_key')), - - Forms\Components\Boolean::make('sudo') - ->label(trans('froxlor-core::generic.sudo')), - ]), + ]) ]; } diff --git a/packages/core/src/Resources/Plans/Schemas/PlanView.php b/packages/core/src/Resources/Plans/Schemas/PlanView.php index 200c40d..289799f 100644 --- a/packages/core/src/Resources/Plans/Schemas/PlanView.php +++ b/packages/core/src/Resources/Plans/Schemas/PlanView.php @@ -3,7 +3,7 @@ namespace Froxlor\Core\Resources\Plans\Schemas; use Froxlor\Core\Models\Plan; -use Froxlor\Core\Resources\Plans\Relations\Resource\Schemas\ResourceTable; +use Froxlor\Core\Resources\Plans\Relations\Resources\Schemas\ResourceTable; use Froxlor\UI\Forms; use Froxlor\UI\Schemas; diff --git a/packages/core/src/Resources/Tenants/Relations/Environments/Schemas/EnvironmentView.php b/packages/core/src/Resources/Tenants/Relations/Environments/Schemas/EnvironmentView.php index f508c41..0b3cb62 100644 --- a/packages/core/src/Resources/Tenants/Relations/Environments/Schemas/EnvironmentView.php +++ b/packages/core/src/Resources/Tenants/Relations/Environments/Schemas/EnvironmentView.php @@ -5,7 +5,6 @@ use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Resources\AuditLogs\Tables\AuditLogTable; -use Froxlor\Core\Resources\Plans\Tables\PlanTable; use Froxlor\Core\Resources\Users\Tables\UserTable; use Froxlor\UI\Schemas; use Froxlor\UI\Tables; @@ -47,17 +46,6 @@ public static function schema(Tenant $tenant, Environment $environment): array ->columns(UserTable::columns()) ->actions([]), ]), - Schemas\Components\Tab::make('tenants.environments.show.tabs.plans') - ->sort(2000) - ->label(trans('froxlor-core::generic.plans')) - ->components([ - Schemas\Components\Relation::make('plans') - ->fetch(route('api.tenants.environments.plans.index', [$tenant, $environment])) - //->intendedRoute('tenants.environments.plans.show', ['tenant' => $tenant->id, 'environment' => $environment->id, 'plan' => {id}']) - ->columns(PlanTable::columns()) - ->actions([]), - ]), - Schemas\Components\Tab::make('tenants.environments.show.tabs.log') ->sort(9999) ->label(trans('froxlor-core::generic.audit-log')) diff --git a/packages/core/src/Services/Bootstrap/BootstrapService.php b/packages/core/src/Services/Bootstrap/BootstrapService.php index 602bcae..21f3595 100644 --- a/packages/core/src/Services/Bootstrap/BootstrapService.php +++ b/packages/core/src/Services/Bootstrap/BootstrapService.php @@ -15,14 +15,11 @@ public function initRootTenant(string $email, string $firstName, string $lastNam { // create master tenant $tenant = Tenant::query()->create([ - 'plan_id' => Plan::query()->where('name', 'Unlimited')->first()->id, + 'plan_id' => Plan::query()->where('name', 'Platform Unlimited')->first()->id, 'name' => 'Froxlor', 'description' => 'Froxlor Master Tenant' ]); - // let the tenant own the "plans and roles" plan - Plan::query()->where('name', 'Plans and roles')->first()->update(['tenant_id' => $tenant->id]); - // create root user $user = User::query()->create([ 'first_name' => $firstName, diff --git a/packages/core/src/Services/Traits/IsEnvironmentResource.php b/packages/core/src/Services/Traits/IsEnvironmentResource.php new file mode 100644 index 0000000..27248a2 --- /dev/null +++ b/packages/core/src/Services/Traits/IsEnvironmentResource.php @@ -0,0 +1,11 @@ + 7, + 'info' => 6, + 'notice' => 5, + 'warning' => 4, + 'error' => 3, + 'critical' => 2, + 'alert' => 1, + 'emergency' => 0, + ]; + + public static function __callStatic(string $method, array $args): void + { + if (!isset(self::LEVELS[$method])) { + throw new BadMethodCallException(); + } + + self::log( + $args[0], + $args[1] ?? null, + $args[2] ?? null, + $args[3] ?? null, + self::LEVELS[$method] + ); + } + /** * @param string $audit_content * @param Tenant|null $tenant * @param Environment|null $environment * @param array|null $context - * + * @param int|null $severity * @return void */ - public static function log(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null): void + public static function log(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null, ?int $severity = 5): void { $auditable = request()->user() ?? auth()->user(); if (empty($auditable)) { $auditable = null; } - self::log_internal($auditable, $audit_content, $tenant, $environment, $context); + self::log_internal($auditable, $audit_content, $tenant, $environment, $context, $severity); } /** @@ -32,13 +69,13 @@ public static function log(string $audit_content, ?Tenant $tenant = null, ?Envir * @param Tenant|null $tenant * @param Environment|null $environment * @param array|null $context - * + * @param int|null $severity * @return void * @internal */ - private static function log_internal(?User $auditable, string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null): void + private static function log_internal(?User $auditable, string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null, ?int $severity = 5): void { - if (Setting::get('auditlog.enabled')) { + if (Setting::get('auditlog.enabled') && Setting::get('auditlog.severity') >= $severity) { if (empty($context)) { $context = []; } diff --git a/packages/core/src/Support/PlanAssignments.php b/packages/core/src/Support/PlanAssignments.php new file mode 100644 index 0000000..e7efeb0 --- /dev/null +++ b/packages/core/src/Support/PlanAssignments.php @@ -0,0 +1,777 @@ +plan !== null) { + self::ensureTenantUserUsageWithinPlan($tenant, $userId, $tenant->plan, $field); + } + return; + } + + $plan = Plan::query()->with('resources')->findOrFail($planId); + + if (!$plan->isAvailableForTenant($tenant)) { + throw self::validationException($field, 'The selected plan is not available for this tenant.'); + } + + self::ensureHasEnabledResources($plan, $field, 'The selected plan does not contain enabled resources.'); + self::ensureWithinParentPlan($plan, $tenant->plan, $field); + + if ($userId !== null) { + self::ensureTenantUserUsageWithinPlan($tenant, $userId, $plan, $field); + } + } + + /** + * Ensure that a plan can be assigned as an optional environment-user limit plan. + * + * Environment users inherit the environment plan when no explicit plan is assigned. + * An explicit environment-user plan must stay within the environment's total plan so + * per-user limits never exceed the scope's total resource budget. + * + * @throws ValidationException + */ + public static function ensureAssignableToEnvironmentUser( + ?string $planId, + Tenant $tenant, + Environment $environment, + string $field = 'environment_plan', + ?string $userId = null, + ): void { + if (empty($planId)) { + $parentPlan = self::environmentParentPlan($environment); + if ($userId !== null && $parentPlan !== null) { + self::ensureEnvironmentUserUsageWithinPlan($environment, $userId, $parentPlan, $field); + } + return; + } + + $plan = Plan::query()->with('resources')->findOrFail($planId); + + if (!$plan->isAvailableForTenant($tenant)) { + throw self::validationException($field, 'The selected plan is not available for this environment.'); + } + + self::ensureHasEnabledResources($plan, $field, 'The selected plan does not contain enabled resources.'); + self::ensureWithinParentPlan($plan, self::environmentParentPlan($environment), $field); + + if ($userId !== null) { + self::ensureEnvironmentUserUsageWithinPlan($environment, $userId, $plan, $field); + } + } + + /** + * Ensure a plan is not used before deletion. + * + * Plans are quota-bearing objects. Deleting an assigned plan would change resource + * enforcement for tenants, environments, or user pivots implicitly, so API deletes + * reject used plans with a validation response. + * + * @throws ValidationException + */ + public static function ensureNotAssigned(Plan $plan): void + { + $assignments = [ + 'tenants' => DB::table('tenants')->where('plan_id', $plan->id)->count(), + 'environments' => DB::table('environments')->where('plan_id', $plan->id)->count(), + 'tenant users' => DB::table('tenant_user')->where('plan_id', $plan->id)->count(), + 'environment users' => DB::table('environment_user')->where('plan_id', $plan->id)->count(), + 'tenant reservations' => DB::table('tenant_resource_reservations')->where('plan_id', $plan->id)->count(), + ]; + + $usedBy = collect($assignments) + ->filter() + ->keys() + ->implode(', '); + + if ($usedBy !== '') { + throw self::validationException('plan', 'The plan is still assigned to ' . $usedBy . '.'); + } + } + + /** + * Assign or update one resource limit and revalidate every existing plan assignment. + * + * Used plans stay editable, but the resulting plan must still fit all places where + * it is assigned. For child tenants, reservations are synchronized after successful + * validation so parent budgets immediately reflect the changed limits. + * + * @throws ValidationException + */ + public static function updatePlanResourceLimit(Plan $plan, Resource $resource, int $limit, ?Tenant $tenant = null): void + { + DB::transaction(function () use ($plan, $resource, $limit, $tenant): void { + self::lockPlanAssignments($plan); + self::ensureResourceCanBeAttached($plan, $resource, $limit, $tenant, 'limit'); + + $plan->resources()->syncWithoutDetaching([ + $resource->id => ['limit' => $limit], + ]); + + self::ensureAssignedPlanRemainsValid($plan->refresh()); + self::syncReservationsForAssignedTenants($plan->refresh()); + }); + } + + /** + * Remove one resource from a plan and revalidate every existing plan assignment. + * + * Removing a resource is equivalent to setting its limit to unavailable. It is only + * allowed when no current assignment or reservation still depends on that resource. + * + * @throws ValidationException + */ + public static function removePlanResource(Plan $plan, Resource $resource): void + { + DB::transaction(function () use ($plan, $resource): void { + self::lockPlanAssignments($plan); + + $plan->resources()->detach($resource); + + self::ensureAssignedPlanRemainsValid($plan->refresh()); + self::syncReservationsForAssignedTenants($plan->refresh()); + }); + } + + /** + * Ensure a resource can be attached to the given plan with the requested limit. + * + * For tenant-owned plans, resource limits must stay within the owning + * tenant's assigned plan so tenants cannot create child plans that grant more + * capacity than they own. + * + * @throws ValidationException + */ + public static function ensureResourceCanBeAttached( + Plan $plan, + Resource $resource, + int $limit, + ?Tenant $tenant = null, + string $field = 'resource_id', + ): void { + if ($tenant === null || $limit === 0) { + return; + } + + $parentPlan = $tenant->plan; + if ($parentPlan === null) { + throw self::validationException('limit', 'The resource cannot be assigned without a parent plan.'); + } + + $parentResource = $parentPlan->resources() + ->where('resources.key', $resource->key) + ->where('resources.type', $resource->type) + ->first(); + $parentLimit = $parentResource === null ? null : (int)$parentResource->pivot->limit; + + if ($parentLimit === null || $parentLimit === 0) { + throw self::validationException('limit', 'The selected resource is not available in the parent plan.'); + } + + if ($limit === -1 && $parentLimit !== -1) { + throw self::validationException('limit', 'The selected resource grants unlimited usage above the parent plan.'); + } + + if ($limit > 0 && $parentLimit !== -1 && $limit > $parentLimit) { + throw self::validationException('limit', 'The selected resource limit is above the parent plan.'); + } + } + + /** + * Ensure every enabled resource in a child plan fits into the parent plan. + * + * Limit semantics are: `0` means no access, `-1` means unlimited, and positive + * values are finite limits. A child plan may omit a resource or set it to `0`, but it + * cannot grant a resource missing from the parent, grant unlimited unless the parent + * is unlimited, or set a finite limit above the parent limit. + * + * @throws ValidationException + */ + public static function ensureWithinParentPlan(Plan $childPlan, ?Plan $parentPlan, string $field = 'plan_id'): void + { + if ($parentPlan === null) { + throw self::validationException($field, 'The selected plan cannot be assigned without a parent plan.'); + } + + $parentResources = $parentPlan->resources() + ->get() + ->mapWithKeys(fn($resource) => [self::resourceIdentifier($resource->key, $resource->type) => (int)$resource->pivot->limit]); + + $childResources = $childPlan->resources()->get(); + + foreach ($childResources as $childResource) { + $childLimit = (int)$childResource->pivot->limit; + + if ($childLimit === 0) { + continue; + } + + $parentLimit = $parentResources->get(self::resourceIdentifier($childResource->key, $childResource->type)); + + if ($parentLimit === null || $parentLimit === 0) { + throw self::validationException($field, 'The selected plan grants resources that are not available in the parent plan.'); + } + + if ($childLimit === -1 && $parentLimit !== -1) { + throw self::validationException($field, 'The selected plan grants unlimited resources above the parent plan.'); + } + + if ($childLimit > 0 && $parentLimit !== -1 && $childLimit > $parentLimit) { + throw self::validationException($field, 'The selected plan grants resource limits above the parent plan.'); + } + } + } + + /** + * Ensure a tenant-owned plan can be assigned to a direct child tenant. + * + * Tenant-owned plans are reusable templates. Quota is not reserved while the plan + * exists; reservation happens only when the plan is assigned to a child tenant. The + * plan must belong to the parent tenant, its resources must fit within + * the parent's own plan, and those resources must fit into the parent's + * currently available budget after real usage and existing child reservations are + * subtracted. + * + * @throws ValidationException + */ + public static function ensureAssignableToChildTenant(Plan $plan, Tenant $parentTenant, ?Tenant $childTenant = null, string $field = 'plan_id'): void + { + if ($plan->tenant_id !== $parentTenant->id) { + throw self::validationException($field, 'The selected plan is not available for child tenants.'); + } + + self::ensureWithinParentPlan($plan->loadMissing('resources'), $parentTenant->plan, $field); + self::ensureWithinAvailableTenantBudget($plan, $parentTenant, $childTenant, $field); + + if ($childTenant !== null) { + self::ensureTenantUsageWithinPlan($childTenant, $plan, $field); + } + } + + /** + * Ensure a plan can be used inside the tenant context. + * + * @throws ValidationException + */ + public static function ensurePlanAvailableForTenant(?string $planId, Tenant $tenant, string $field = 'plan_id'): void + { + if ($planId === null) { + return; + } + + $plan = Plan::query()->findOrFail($planId); + + if (!$plan->isAvailableForTenant($tenant)) { + throw self::validationException($field, trans('validation.exists', ['attribute' => $field])); + } + } + + /** + * Ensure a plan can be assigned directly to an environment. + * + * Environment plans are budget contracts inside the tenant budget. They may be + * global or tenant-owned, but all enabled resources must exist in and stay within + * the tenant's own plan. + * + * @throws ValidationException + */ + public static function ensureAssignableToEnvironment(?string $planId, Tenant $tenant, string $field = 'plan_id', ?Environment $environment = null): void + { + if ($planId === null) { + if ($environment !== null && $tenant->plan !== null) { + self::ensureEnvironmentUsageWithinPlan($environment, $tenant->plan, $field); + } + return; + } + + $plan = Plan::query()->with('resources')->findOrFail($planId); + + if (!$plan->isAvailableForTenant($tenant)) { + throw self::validationException($field, trans('validation.exists', ['attribute' => $field])); + } + + self::ensureHasEnabledResources($plan, $field, 'The selected plan does not contain enabled resources.'); + self::ensureWithinParentPlan($plan, $tenant->plan, $field); + + if ($environment !== null) { + self::ensureEnvironmentUsageWithinPlan($environment, $plan, $field); + } + } + + /** + * Lock the tenant budget rows used by child-tenant reservation checks. + * + * Call this inside the same transaction that validates and writes reservations. + */ + public static function lockTenantBudget(Tenant $tenant): void + { + Tenant::query() + ->whereKey($tenant->id) + ->lockForUpdate() + ->first(); + + TenantResourceReservation::query() + ->where('tenant_id', $tenant->id) + ->lockForUpdate() + ->get(); + } + + /** + * Lock rows that participate in validating a currently assigned plan mutation. + */ + private static function lockPlanAssignments(Plan $plan): void + { + Plan::query()->whereKey($plan->id)->lockForUpdate()->first(); + + DB::table('tenants')->where('plan_id', $plan->id)->lockForUpdate()->get(); + DB::table('environments')->where('plan_id', $plan->id)->lockForUpdate()->get(); + DB::table('tenant_user')->where('plan_id', $plan->id)->lockForUpdate()->get(); + DB::table('environment_user')->where('plan_id', $plan->id)->lockForUpdate()->get(); + DB::table('tenant_resource_reservations')->where('plan_id', $plan->id)->lockForUpdate()->get(); + + foreach (self::tenantsAssignedToPlan($plan) as $tenant) { + if ($tenant->parentTenant !== null) { + self::lockTenantBudget($tenant->parentTenant); + } + self::lockTenantBudget($tenant); + } + } + + /** + * Persist reservations for every enabled resource in the plan. + * + * Existing reservations for the same child tenant are replaced so plan changes and + * tenant plan switches leave no stale budget assignments behind. + */ + public static function syncTenantReservations(Tenant $parentTenant, Tenant $childTenant, Plan $plan): void + { + TenantResourceReservation::query() + ->where('tenant_id', $parentTenant->id) + ->where('reserved_for_tenant_id', $childTenant->id) + ->delete(); + + foreach (self::planLimits($plan) as $resourceLimit) { + if ($resourceLimit['limit'] === 0) { + continue; + } + + TenantResourceReservation::query()->create([ + 'tenant_id' => $parentTenant->id, + 'reserved_for_tenant_id' => $childTenant->id, + 'plan_id' => $plan->id, + 'resource_key' => $resourceLimit['key'], + 'resource_type' => $resourceLimit['type'], + 'limit' => $resourceLimit['limit'], + ]); + } + } + + /** + * Resynchronize reservations for every child tenant currently using this plan. + */ + private static function syncReservationsForAssignedTenants(Plan $plan): void + { + foreach (self::tenantsAssignedToPlan($plan) as $tenant) { + if ($plan->tenant_id === null || $tenant->parentTenant === null) { + continue; + } + + self::syncTenantReservations($tenant->parentTenant, $tenant, $plan); + } + } + + /** + * Drop quota reservations held by the given parent for the child tenant. + */ + public static function removeTenantReservations(Tenant $parentTenant, Tenant $childTenant): void + { + TenantResourceReservation::query() + ->where('tenant_id', $parentTenant->id) + ->where('reserved_for_tenant_id', $childTenant->id) + ->delete(); + } + + /** + * Return the currently available quota per resource for a tenant. + * + * Limit semantics are preserved: `-1` stays unlimited, `0` means unavailable, and + * positive values are reduced by real usage and delegated child reservations. + * + * @return array + */ + public static function availableTenantBudget(Tenant $tenant, ?Tenant $ignoreChildTenant = null): array + { + $budget = []; + + foreach (self::planLimits($tenant->plan) as $identifier => $resourceLimit) { + $limit = $resourceLimit['limit']; + + if ($limit === -1) { + $budget[$identifier] = -1; + continue; + } + + $used = self::usageForTenant($tenant, $resourceLimit['key'], $resourceLimit['type']); + + $reserved = TenantResourceReservation::query() + ->where('tenant_id', $tenant->id) + ->where('resource_key', $resourceLimit['key']) + ->where('resource_type', $resourceLimit['type']) + ->when($ignoreChildTenant !== null, fn($query) => $query->where('reserved_for_tenant_id', '!=', $ignoreChildTenant->id)) + ->sum('limit'); + + $budget[$identifier] = max(0, $limit - $used - (int)$reserved); + } + + return $budget; + } + + /** + * Ensure the plan's enabled limits fit into the parent's free budget. + * + * @throws ValidationException + */ + private static function ensureWithinAvailableTenantBudget(Plan $plan, Tenant $parentTenant, ?Tenant $childTenant, string $field): void + { + $available = self::availableTenantBudget($parentTenant, $childTenant); + + foreach (self::planLimits($plan) as $identifier => $resourceLimit) { + $limit = $resourceLimit['limit']; + + if ($limit === 0) { + continue; + } + + $availableLimit = $available[$identifier] ?? 0; + + if ($availableLimit === -1) { + continue; + } + + if ($limit === -1 || $limit > $availableLimit) { + throw self::validationException($field, 'The selected plan exceeds the parent tenant available resource budget.'); + } + } + } + + /** + * Validate all existing assignments after a plan resource mutation. + * + * @throws ValidationException + */ + private static function ensureAssignedPlanRemainsValid(Plan $plan): void + { + foreach (self::tenantsAssignedToPlan($plan) as $tenant) { + if ($plan->tenant_id !== null && $tenant->parentTenant !== null) { + self::ensureAssignableToChildTenant($plan, $tenant->parentTenant, $tenant, 'plan'); + } + + self::ensureTenantUsageWithinPlan($tenant, $plan, 'plan'); + } + + foreach (Environment::query()->where('plan_id', $plan->id)->with('tenant')->get() as $environment) { + self::ensureWithinParentPlan($plan, $environment->tenant->plan, 'plan'); + self::ensureEnvironmentUsageWithinPlan($environment, $plan, 'plan'); + } + + foreach (DB::table('tenant_user')->where('plan_id', $plan->id)->get() as $assignment) { + $tenant = Tenant::query()->findOrFail($assignment->tenant_id); + self::ensureAssignableToTenantUser($plan->id, $tenant, 'plan'); + self::ensureTenantUserUsageWithinPlan($tenant, (string)$assignment->user_id, $plan, 'plan'); + } + + foreach (DB::table('environment_user')->where('plan_id', $plan->id)->get() as $assignment) { + $environment = Environment::query()->with('tenant')->findOrFail($assignment->environment_id); + self::ensureAssignableToEnvironmentUser($plan->id, $environment->tenant, $environment, 'plan'); + self::ensureEnvironmentUserUsageWithinPlan($environment, (string)$assignment->user_id, $plan, 'plan'); + } + } + + /** + * Return tenants currently assigned to the given plan. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private static function tenantsAssignedToPlan(Plan $plan): \Illuminate\Database\Eloquent\Collection + { + return Tenant::query() + ->where('plan_id', $plan->id) + ->with('parentTenant') + ->get(); + } + + /** + * Ensure a tenant's current usage and delegated reservations fit into a plan. + * + * @throws ValidationException + */ + private static function ensureTenantUsageWithinPlan(Tenant $tenant, Plan $plan, string $field): void + { + foreach (self::planLimits($plan) as $identifier => $resourceLimit) { + $used = self::usageForTenant($tenant, $resourceLimit['key'], $resourceLimit['type']) + + self::reservedByTenant($tenant, $resourceLimit['key'], $resourceLimit['type']); + + self::ensureLimitCoversUsage($resourceLimit['limit'], $used, $field); + } + + self::ensureNoUsageOutsidePlan($plan, self::tenantUsageIdentifiers($tenant), $field); + } + + /** + * Ensure an environment's current usage fits into a plan. + * + * @throws ValidationException + */ + private static function ensureEnvironmentUsageWithinPlan(Environment $environment, Plan $plan, string $field): void + { + foreach (self::planLimits($plan) as $resourceLimit) { + if ($resourceLimit['type'] !== 'environment') { + continue; + } + + self::ensureLimitCoversUsage( + $resourceLimit['limit'], + self::usageForEnvironment($environment, $resourceLimit['key']), + $field, + ); + } + + self::ensureNoUsageOutsidePlan($plan, self::environmentUsageIdentifiers($environment), $field); + } + + /** + * Ensure a tenant user's current usage fits into its explicit plan. + * + * @throws ValidationException + */ + private static function ensureTenantUserUsageWithinPlan(Tenant $tenant, string $userId, Plan $plan, string $field): void + { + foreach (self::planLimits($plan) as $resourceLimit) { + if ($resourceLimit['type'] !== 'tenant') { + continue; + } + + self::ensureLimitCoversUsage( + $resourceLimit['limit'], + self::usageForTenant($tenant, $resourceLimit['key'], 'tenant', $userId), + $field, + ); + } + } + + /** + * Ensure an environment user's current usage fits into its explicit plan. + * + * @throws ValidationException + */ + private static function ensureEnvironmentUserUsageWithinPlan(Environment $environment, string $userId, Plan $plan, string $field): void + { + foreach (self::planLimits($plan) as $resourceLimit) { + if ($resourceLimit['type'] !== 'environment') { + continue; + } + + self::ensureLimitCoversUsage( + $resourceLimit['limit'], + self::usageForEnvironment($environment, $resourceLimit['key'], $userId), + $field, + ); + } + } + + /** + * Reject a plan mutation when current usage would exceed the new limit. + * + * @throws ValidationException + */ + private static function ensureLimitCoversUsage(int $limit, int $used, string $field): void + { + if ($limit === -1 || $used === 0) { + return; + } + + if ($limit <= 0 || $used > $limit) { + throw self::validationException($field, 'The plan is already used above the requested resource limit.'); + } + } + + /** + * Reject removing resources that still have usage records. + * + * @param array $usageIdentifiers + * @throws ValidationException + */ + private static function ensureNoUsageOutsidePlan(Plan $plan, array $usageIdentifiers, string $field): void + { + $planIdentifiers = array_keys(self::planLimits($plan)); + + foreach ($usageIdentifiers as $usageIdentifier) { + if (!in_array($usageIdentifier, $planIdentifiers, true)) { + throw self::validationException($field, 'The plan resource is already in use and cannot be removed.'); + } + } + } + + private static function reservedByTenant(Tenant $tenant, string $resourceKey, string $resourceType): int + { + return (int)TenantResourceReservation::query() + ->where('tenant_id', $tenant->id) + ->where('resource_key', $resourceKey) + ->where('resource_type', $resourceType) + ->sum('limit'); + } + + /** + * Return resource limits keyed by resource type and key for the given plan. + * + * @return array + */ + private static function planLimits(Plan $plan): array + { + return $plan->resources() + ->get() + ->mapWithKeys(fn($resource) => [ + self::resourceIdentifier($resource->key, $resource->type) => [ + 'key' => $resource->key, + 'type' => $resource->type, + 'limit' => (int)$resource->pivot->limit, + ], + ]) + ->all(); + } + + /** + * Ensure that an explicitly assigned plan contains enabled resources. + * + * Plans without enabled limits are valid as templates, but assigning them as an + * explicit user limit plan would grant no usable budget. + * + * @throws ValidationException + */ + private static function ensureHasEnabledResources(Plan $plan, string $field, string $message): void + { + $hasEnabledResource = $plan->resources() + ->wherePivot('limit', '!=', 0) + ->exists(); + + if (!$hasEnabledResource) { + throw self::validationException($field, $message); + } + } + + private static function resourceIdentifier(string $key, string $type): string + { + return $type . ':' . $key; + } + + private static function usageForTenant(Tenant $tenant, string $resourceKey, string $resourceType, ?string $userId = null): int + { + if ($resourceType === 'environment') { + return (int)DB::table('env_usage') + ->join('environments', 'env_usage.environment_id', '=', 'environments.id') + ->where('environments.tenant_id', $tenant->id) + ->where('env_usage.resource_key', $resourceKey) + ->when($userId !== null, fn($query) => $query->where('env_usage.user_id', $userId)) + ->count(); + } + + return (int)DB::table('tenant_usage') + ->where('tenant_id', $tenant->id) + ->where('resource_key', $resourceKey) + ->when($userId !== null, fn($query) => $query->where('user_id', $userId)) + ->count(); + } + + private static function usageForEnvironment(Environment $environment, string $resourceKey, ?string $userId = null): int + { + return (int)DB::table('env_usage') + ->where('environment_id', $environment->id) + ->where('resource_key', $resourceKey) + ->when($userId !== null, fn($query) => $query->where('user_id', $userId)) + ->count(); + } + + /** + * @return array + */ + private static function tenantUsageIdentifiers(Tenant $tenant): array + { + $tenantUsage = DB::table('tenant_usage') + ->where('tenant_id', $tenant->id) + ->select('resource_key') + ->distinct() + ->pluck('resource_key') + ->map(fn(string $key) => self::resourceIdentifier($key, 'tenant')); + + $environmentUsage = DB::table('env_usage') + ->join('environments', 'env_usage.environment_id', '=', 'environments.id') + ->where('environments.tenant_id', $tenant->id) + ->select('env_usage.resource_key') + ->distinct() + ->pluck('resource_key') + ->map(fn(string $key) => self::resourceIdentifier($key, 'environment')); + + $reserved = TenantResourceReservation::query() + ->where('tenant_id', $tenant->id) + ->select('resource_key', 'resource_type') + ->distinct() + ->get() + ->map(fn(TenantResourceReservation $reservation) => self::resourceIdentifier($reservation->resource_key, $reservation->resource_type)); + + return $tenantUsage + ->merge($environmentUsage) + ->merge($reserved) + ->unique() + ->values() + ->all(); + } + + /** + * @return array + */ + private static function environmentUsageIdentifiers(Environment $environment): array + { + return DB::table('env_usage') + ->where('environment_id', $environment->id) + ->select('resource_key') + ->distinct() + ->pluck('resource_key') + ->map(fn(string $key) => self::resourceIdentifier($key, 'environment')) + ->values() + ->all(); + } + + private static function environmentParentPlan(Environment $environment): ?Plan + { + return $environment->plan ?: $environment->tenant->plan; + } + + private static function validationException(string $field, string $message): ValidationException + { + return ValidationException::withMessages([ + $field => $message, + ]); + } +} diff --git a/packages/core/src/Support/Resource.php b/packages/core/src/Support/Resource.php index db0b9d2..011f3ed 100644 --- a/packages/core/src/Support/Resource.php +++ b/packages/core/src/Support/Resource.php @@ -212,7 +212,8 @@ public static function actingTenantFor(User $user, Tenant $targetTenant): ?Tenan private static function tenantPlanHasResourceAvailable(Tenant $tenant, string|Model $resource): bool { $resourceToCheck = $tenant->plan?->resources() - ->where('key', self::resourceKey($resource)) + ->where('resources.key', self::resourceKey($resource)) + ->where('resources.type', 'tenant') ->first(); if (empty($resourceToCheck)) { diff --git a/packages/core/src/Support/ResourceRegistry.php b/packages/core/src/Support/ResourceRegistry.php new file mode 100644 index 0000000..0386051 --- /dev/null +++ b/packages/core/src/Support/ResourceRegistry.php @@ -0,0 +1,274 @@ +, type: string, source: string}> + */ + private static array $resources = []; + + /** + * Register resource definitions exposed by a package or application component. + * + * Resource keys are unique per scope. The same key may exist in tenant and + * environment scope, but a package must not register two different model classes + * for the same key and scope because plan limits would become ambiguous. + * + * @param array, type: string, description?: string|null}> $resources + * @throws InvalidArgumentException + * @throws LogicException + */ + public static function register(array $resources, string $source): void + { + foreach ($resources as $resource) { + self::registerResource($resource, $source); + } + } + + /** + * Register resource definitions declared by an Eloquent model. + * + * Models must use `IsResource` plus at least one scope marker trait + * (`IsTenantResource` or `IsEnvironmentResource`). This keeps internal models that + * use the base resource contract from becoming plan resources accidentally. + * + * @param class-string $modelClass + * @throws InvalidArgumentException + * @throws LogicException + */ + public static function registerModel(string $modelClass, ?string $source = null): void + { + if (!class_exists($modelClass)) { + throw new InvalidArgumentException('Resource model does not exist: ' . $modelClass); + } + + if (!is_a($modelClass, Model::class, true)) { + throw new InvalidArgumentException('Resource model must be an Eloquent model: ' . $modelClass); + } + + $traits = class_uses_recursive($modelClass); + if (!in_array(IsResource::class, $traits, true)) { + return; + } + + $types = []; + if (in_array(IsTenantResource::class, $traits, true)) { + $types[] = 'tenant'; + } + if (in_array(IsEnvironmentResource::class, $traits, true)) { + $types[] = 'environment'; + } + if ($types === []) { + return; + } + + $resources = []; + foreach ($types as $type) { + $resources[] = [ + 'key' => $modelClass::getResourceKey(), + 'name' => self::defaultName($modelClass, $type, count($types) > 1), + 'model_type' => $modelClass, + 'type' => $type, + ]; + } + + self::register($resources, $source ?? $modelClass); + } + + /** + * Register resource-aware models in a package model directory. + * + * The scan mirrors `PermissionRegistry`: standard packages only need models under + * `src/Models`; packages with custom layouts can call `registerModel()` manually. + * + * @throws InvalidArgumentException + * @throws LogicException + */ + public static function registerModelsFrom(string $directory, string $namespace, ?string $source = null): void + { + if (!is_dir($directory)) { + return; + } + + foreach (new FilesystemIterator($directory) as $fileInfo) { + if ($fileInfo->isDir() || $fileInfo->getExtension() !== 'php') { + continue; + } + + self::registerModel( + rtrim($namespace, '\\') . '\\' . $fileInfo->getBasename('.php'), + $source ?? $namespace, + ); + } + } + + /** + * Register resource-aware models from every package using the standard layout. + * + * Packages are discovered through their `composer.json` PSR-4 autoload metadata. + * Only models with `IsResource` and at least one scope marker are registered. + * + * @throws InvalidArgumentException + * @throws LogicException + */ + public static function registerPackageModelsFrom(string $packagesDirectory): void + { + if (!is_dir($packagesDirectory)) { + return; + } + + foreach (new FilesystemIterator($packagesDirectory) as $packageDirectory) { + if (!$packageDirectory->isDir()) { + continue; + } + + $composerFile = $packageDirectory->getPathname() . '/composer.json'; + if (!is_file($composerFile)) { + continue; + } + + self::registerPackageModels($composerFile); + } + } + + /** + * Return all registered resources sorted by scope and key. + * + * @return array, type: string, source: string}> + */ + public static function all(): array + { + $resources = array_values(self::$resources); + + usort($resources, fn(array $left, array $right) => [$left['type'], $left['key']] <=> [$right['type'], $right['key']]); + + return $resources; + } + + /** + * Synchronize registered resource definitions into the database. + * + * Existing resources keep their ULIDs and plan assignments. Registry sync only + * updates metadata owned by package/resource definitions. + */ + public static function sync(): void + { + foreach (self::all() as $resource) { + Resource::query()->updateOrCreate( + [ + 'key' => $resource['key'], + 'model_type' => $resource['model_type'], + 'type' => $resource['type'], + ], + [ + 'name' => $resource['name'], + 'description' => $resource['description'] ?? null, + ], + ); + } + } + + /** + * Register resource-aware models for one package composer file. + */ + private static function registerPackageModels(string $composerFile): void + { + $composer = json_decode((string)file_get_contents($composerFile), true); + if (!is_array($composer)) { + throw new InvalidArgumentException('Unable to read package composer metadata: ' . $composerFile); + } + + $packageRoot = dirname($composerFile); + $packageName = $composer['name'] ?? basename($packageRoot); + $autoload = $composer['autoload']['psr-4'] ?? []; + + foreach ($autoload as $namespace => $path) { + foreach ((array)$path as $autoloadPath) { + $normalizedPath = trim((string)$autoloadPath, '/'); + + if (!Str::endsWith($normalizedPath, 'src')) { + continue; + } + + self::registerModelsFrom( + $packageRoot . '/' . $normalizedPath . '/Models', + rtrim((string)$namespace, '\\') . '\\Models', + (string)$packageName, + ); + } + } + } + + /** + * Register one validated resource definition. + * + * @param array{key?: string, name?: string, model_type?: class-string, type?: string, description?: string|null} $resource + */ + private static function registerResource(array $resource, string $source): void + { + if (empty($resource['key']) || !is_string($resource['key'])) { + throw new InvalidArgumentException('Registered resources require a non-empty string key.'); + } + + if (empty($resource['name']) || !is_string($resource['name'])) { + throw new InvalidArgumentException('Registered resource "' . $resource['key'] . '" requires a non-empty string name.'); + } + + if (empty($resource['model_type']) || !is_string($resource['model_type']) || !is_a($resource['model_type'], Model::class, true)) { + throw new InvalidArgumentException('Registered resource "' . $resource['key'] . '" requires an Eloquent model type.'); + } + + if (!in_array($resource['type'] ?? null, ['tenant', 'environment'], true)) { + throw new InvalidArgumentException('Registered resource "' . $resource['key'] . '" requires a valid resource type.'); + } + + $registryKey = $resource['type'] . ':' . $resource['key']; + $registered = self::$resources[$registryKey] ?? null; + + if ($registered !== null && ($registered['source'] !== $source || $registered['model_type'] !== $resource['model_type'])) { + throw new LogicException(sprintf( + 'Resource key "%s" for type "%s" is already registered by "%s" and cannot be registered by "%s".', + $resource['key'], + $resource['type'], + $registered['source'], + $source, + )); + } + + self::$resources[$registryKey] = [ + 'key' => $resource['key'], + 'name' => $resource['name'], + 'description' => $resource['description'] ?? null, + 'model_type' => $resource['model_type'], + 'type' => $resource['type'], + 'source' => $source, + ]; + } + + /** + * Build a human-readable default name for model-based resource definitions. + * + * User resources exist in both scopes, so the scope prefix avoids ambiguous plan + * editing labels while keeping package defaults simple. + * + * @param class-string $modelClass + */ + private static function defaultName(string $modelClass, string $type, bool $includeScope): string + { + $key = str_replace('.', ' ', $modelClass::getResourceKey()); + + return Str::headline($includeScope ? $type . ' ' . $key : $key); + } +} diff --git a/packages/core/src/Support/Setting.php b/packages/core/src/Support/Setting.php index 48b3b13..fd23d9b 100644 --- a/packages/core/src/Support/Setting.php +++ b/packages/core/src/Support/Setting.php @@ -171,6 +171,7 @@ private static function castValue(mixed $value, ?string $type): mixed { return match ($type) { 'bool' => (bool)$value, + 'integer' => intval($value), default => $value, }; } diff --git a/packages/core/tests/Feature/EnvironmentResourceUsageTest.php b/packages/core/tests/Feature/EnvironmentResourceUsageTest.php index 3c2eec2..1668754 100644 --- a/packages/core/tests/Feature/EnvironmentResourceUsageTest.php +++ b/packages/core/tests/Feature/EnvironmentResourceUsageTest.php @@ -17,7 +17,7 @@ public function test_tenant_environment_creation_records_resource_usage(): void $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $tenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); - $tenant->update(['plan_id' => Plan::query()->where('name', 'Unlimited')->firstOrFail()->id]); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); $environmentId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/environments', [ @@ -42,7 +42,6 @@ public function test_tenant_environment_creation_respects_plan_resource_limit(): $tenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); $plan = Plan::query()->create([ 'name' => 'Single Environment Limit ' . str()->ulid(), - 'type' => 'tenant', ]); $plan->resources()->attach($resource, ['limit' => 1]); $tenant->update(['plan_id' => $plan->id]); @@ -68,8 +67,8 @@ public function test_parent_tenant_user_creating_environment_for_subtenant_count $parentTenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); $subTenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); - $parentTenant->update(['plan_id' => Plan::query()->where('name', 'Unlimited')->firstOrFail()->id]); - $subTenant->update(['plan_id' => Plan::query()->where('name', 'Unlimited')->firstOrFail()->id]); + $parentTenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); + $subTenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); $this->actingAs($user, 'sanctum'); @@ -97,7 +96,7 @@ public function test_tenant_environment_actions_write_audit_log_with_tenant_and_ $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $tenant->tenantUsages()->where('resource_key', Environment::getResourceKey())->delete(); - $tenant->update(['plan_id' => Plan::query()->where('name', 'Unlimited')->firstOrFail()->id]); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); $environmentId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/environments', [ diff --git a/packages/core/tests/Feature/NodeResourceUsageTest.php b/packages/core/tests/Feature/NodeResourceUsageTest.php index eeba0da..1da365d 100644 --- a/packages/core/tests/Feature/NodeResourceUsageTest.php +++ b/packages/core/tests/Feature/NodeResourceUsageTest.php @@ -32,7 +32,7 @@ public function test_tenant_owned_node_creates_and_removes_resource_usage(): voi $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $tenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); - $tenant->update(['plan_id' => Plan::query()->where('name', 'Unlimited')->firstOrFail()->id]); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); $this->actingAs($user, 'sanctum'); @@ -62,7 +62,6 @@ public function test_tenant_owned_node_creation_respects_plan_resource_limit(): $tenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); $plan = Plan::query()->create([ 'name' => 'Single Node Limit ' . str()->ulid(), - 'type' => 'tenant', ]); $plan->resources()->attach($resource, ['limit' => 1]); $tenant->update(['plan_id' => $plan->id]); @@ -84,8 +83,8 @@ public function test_parent_tenant_user_creating_node_for_subtenant_counts_usage $parentTenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); $subTenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); - $parentTenant->update(['plan_id' => Plan::query()->where('name', 'Unlimited')->firstOrFail()->id]); - $subTenant->update(['plan_id' => Plan::query()->where('name', 'Unlimited')->firstOrFail()->id]); + $parentTenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); + $subTenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); $this->actingAs($user, 'sanctum'); @@ -109,7 +108,7 @@ public function test_node_with_assigned_environments_cannot_be_deleted_and_keeps { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); - $plan = Plan::query()->where('name', 'Unlimited')->firstOrFail(); + $plan = Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail(); $tenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); $tenant->update(['plan_id' => $plan->id]); @@ -146,7 +145,7 @@ public function test_tenant_node_actions_write_audit_log_with_tenant_context(): $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $tenant->tenantUsages()->where('resource_key', Node::getResourceKey())->delete(); - $tenant->update(['plan_id' => Plan::query()->where('name', 'Unlimited')->firstOrFail()->id]); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); $this->actingAs($user, 'sanctum'); diff --git a/packages/core/tests/Feature/PermissionRegistryTest.php b/packages/core/tests/Feature/PermissionRegistryTest.php index aafc465..89da091 100644 --- a/packages/core/tests/Feature/PermissionRegistryTest.php +++ b/packages/core/tests/Feature/PermissionRegistryTest.php @@ -52,12 +52,4 @@ public function test_permission_registry_rejects_conflicting_permission_keys(): ], 'tests/package-b'); } - public function test_package_model_permissions_are_seeded_automatically(): void - { - $this->assertDatabaseHas('permissions', [ - 'key' => 'domains.*', - ]); - - $this->assertTrue(Permission::query()->where('key', 'domains.index')->exists()); - } } diff --git a/packages/core/tests/Feature/PlanAuthorizationTest.php b/packages/core/tests/Feature/PlanAuthorizationTest.php index 2ffbc32..a0e6258 100644 --- a/packages/core/tests/Feature/PlanAuthorizationTest.php +++ b/packages/core/tests/Feature/PlanAuthorizationTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use Froxlor\Core\Models\Plan; +use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; use Tests\TestCase; @@ -35,7 +36,6 @@ public function test_super_admin_can_manage_global_plan(): void $planId = $this->actingAs($user, 'sanctum') ->postJson('/api/plans', [ 'name' => $name, - 'type' => 'tenant', 'description' => 'Created by PlanAuthorizationTest', ]) ->assertCreated() @@ -56,6 +56,66 @@ public function test_super_admin_can_manage_global_plan(): void ->assertNoContent(); } + public function test_assigned_global_plan_cannot_be_deleted(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('name', 'Platform Unlimited')->firstOrFail(); + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $originalPlanId = $tenant->plan_id; + + $tenant->update(['plan_id' => $plan->id]); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/plans/' . $plan->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan']); + + $tenant->update(['plan_id' => $originalPlanId]); + } + + public function test_plan_tenant_id_cannot_be_changed_through_update(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('name', 'Tenant Starter')->firstOrFail(); + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->putJson('/api/plans/' . $plan->id, [ + 'tenant_id' => $tenant->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['tenant_id']); + } + + public function test_super_admin_can_list_global_plan_users(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('name', 'Platform Unlimited')->firstOrFail(); + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $originalPlanId = $tenant->plan_id; + + $tenant->update(['plan_id' => $plan->id]); + + $assignments = collect($this->actingAs($user, 'sanctum') + ->getJson('/api/plans/' . $plan->id . '/users') + ->assertOk() + ->json('data')); + + $this->assertNotNull($assignments->firstWhere('tenant_id', $tenant->id)); + + $tenant->update(['plan_id' => $originalPlanId]); + } + + public function test_tenant_admin_cannot_list_global_plan_users(): void + { + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('name', 'Platform Unlimited')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/plans/' . $plan->id . '/users') + ->assertForbidden(); + } + public function test_tenant_admin_cannot_manage_global_plan(): void { $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); @@ -68,7 +128,6 @@ public function test_tenant_admin_cannot_manage_global_plan(): void $this->actingAs($user, 'sanctum') ->postJson('/api/plans', [ 'name' => 'Forbidden Policy Test Plan ' . str()->ulid(), - 'type' => 'tenant', ]) ->assertForbidden(); diff --git a/packages/core/tests/Feature/PlanResourceAuthorizationTest.php b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php new file mode 100644 index 0000000..c3e1449 --- /dev/null +++ b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php @@ -0,0 +1,188 @@ +where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('name', 'Tenant Starter')->firstOrFail(); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $basePath = '/api/plans/' . $plan->id . '/resources'; + + $plan->resources()->detach($resource); + + $this->actingAs($user, 'sanctum') + ->getJson($basePath) + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->postJson($basePath, [ + 'resource_id' => $resource->id, + 'limit' => 4, + ]) + ->assertOk(); + + $this->assertSame(4, (int)$plan->resources() + ->where('resources.id', $resource->id) + ->firstOrFail() + ->pivot + ->limit); + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', + ]); + + $this->actingAs($user, 'sanctum') + ->deleteJson($basePath . '/' . $resource->id) + ->assertOk(); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', + ]); + } + + public function test_plan_resource_index_lists_assigned_and_unassigned_resources(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('name', 'Tenant Starter')->firstOrFail(); + $assignedResource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $unassignedResource = Resource::query()->where('type', 'tenant')->where('key', 'roles')->firstOrFail(); + + $plan->resources()->syncWithoutDetaching([ + $assignedResource->id => ['limit' => 3], + ]); + $plan->resources()->detach($unassignedResource); + + $resources = collect($this->actingAs($user, 'sanctum') + ->getJson('/api/plans/' . $plan->id . '/resources') + ->assertOk() + ->json('data')); + + $assigned = $resources->firstWhere('id', $assignedResource->id); + $unassigned = $resources->firstWhere('id', $unassignedResource->id); + + $this->assertTrue($assigned['assigned']); + $this->assertSame(3, $assigned['limit']); + $this->assertFalse($unassigned['assigned']); + $this->assertSame(0, $unassigned['limit']); + } + + public function test_plan_resource_route_rejects_tenant_plans(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Global Route Tenant Plan ' . str()->ulid(), + ]); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/plans/' . $plan->id . '/resources') + ->assertForbidden(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 1, + ]) + ->assertForbidden(); + } + + public function test_global_plan_can_assign_environment_resource(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('name', 'Tenant Starter')->firstOrFail(); + $resource = Resource::query()->where('type', 'environment')->where('key', 'users')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 1, + ]) + ->assertOk(); + } + + public function test_detaching_unassigned_global_plan_resource_returns_validation_error(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('name', 'Tenant Starter')->firstOrFail(); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'roles')->firstOrFail(); + + $plan->resources()->detach($resource); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/plans/' . $plan->id . '/resources/' . $resource->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['resource_id']); + } + + public function test_assigned_global_plan_resources_can_change_when_usage_fits(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => null, + 'name' => 'Assigned Global Mutable Plan ' . str()->ulid(), + ]); + $plan->resources()->attach($resource, ['limit' => 2]); + Tenant::query()->create([ + 'plan_id' => $plan->id, + 'name' => 'Assigned Global Mutable Tenant ' . str()->ulid(), + ]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 4, + ]) + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 1, + ]) + ->assertOk(); + + } + + public function test_assigned_global_plan_resource_limit_cannot_drop_below_usage(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => null, + 'name' => 'Assigned Global Usage Plan ' . str()->ulid(), + ]); + $plan->resources()->attach($resource, ['limit' => 2]); + $tenant = Tenant::query()->create([ + 'plan_id' => $plan->id, + 'name' => 'Assigned Global Usage Tenant ' . str()->ulid(), + ]); + + TenantUsage::query()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource_key' => $resource->key, + 'resource_id' => User::query()->where('email', 'dev3@froxlor.org')->firstOrFail()->id, + ]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 0, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan']); + + } +} diff --git a/packages/core/tests/Feature/ResourceRegistryTest.php b/packages/core/tests/Feature/ResourceRegistryTest.php new file mode 100644 index 0000000..cb1bdaa --- /dev/null +++ b/packages/core/tests/Feature/ResourceRegistryTest.php @@ -0,0 +1,94 @@ +first(fn(array $resource) => $resource['key'] === 'tests.fake-resource-models' && $resource['type'] === 'tenant'); + $environmentResource = collect(ResourceRegistry::all()) + ->first(fn(array $resource) => $resource['key'] === 'tests.fake-resource-models' && $resource['type'] === 'environment'); + + $this->assertSame($model::class, $tenantResource['model_type']); + $this->assertSame('Tenant Tests Fake Resource Models', $tenantResource['name']); + $this->assertSame($model::class, $environmentResource['model_type']); + $this->assertSame('Environment Tests Fake Resource Models', $environmentResource['name']); + } + + public function test_resource_registry_rejects_conflicting_resource_keys_per_scope(): void + { + ResourceRegistry::register([ + [ + 'key' => 'tests.registry.conflict', + 'name' => 'First resource registration', + 'model_type' => User::class, + 'type' => 'tenant', + ], + ], 'tests/package-a'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Resource key "tests.registry.conflict" for type "tenant" is already registered by "tests/package-a"'); + + ResourceRegistry::register([ + [ + 'key' => 'tests.registry.conflict', + 'name' => 'Second resource registration', + 'model_type' => Model::class, + 'type' => 'tenant', + ], + ], 'tests/package-b'); + } + + public function test_package_model_resources_are_seeded_automatically(): void + { + $this->assertDatabaseHas('resources', [ + 'key' => 'nodes', + 'type' => 'tenant', + 'model_type' => Node::class, + ]); + + $this->assertDatabaseHas('resources', [ + 'key' => 'users', + 'type' => 'tenant', + 'model_type' => User::class, + ]); + + $this->assertDatabaseHas('resources', [ + 'key' => 'users', + 'type' => 'environment', + 'model_type' => User::class, + ]); + + } +} diff --git a/packages/core/tests/Feature/ResourceValidationTest.php b/packages/core/tests/Feature/ResourceValidationTest.php index 7b13d60..b61e71d 100644 --- a/packages/core/tests/Feature/ResourceValidationTest.php +++ b/packages/core/tests/Feature/ResourceValidationTest.php @@ -2,69 +2,50 @@ namespace Tests\Feature; -use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\User; use Tests\TestCase; class ResourceValidationTest extends TestCase { - public function test_resource_model_type_must_be_an_existing_resource_model_class(): void + public function test_super_admin_can_list_available_plan_resources(): void { $user = User::query()->where('email', config('dev.email'))->firstOrFail(); - $this->actingAs($user, 'sanctum') - ->postJson('/api/resources', [ - 'key' => 'invalid-resource-' . str()->ulid(), - 'name' => 'Invalid Resource', - 'model_type' => 'not-a-class', - 'type' => 'tenant', - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['model_type']); + $resources = collect($this->actingAs($user, 'sanctum') + ->getJson('/api/plans/resources') + ->assertOk() + ->json('data')); - $this->actingAs($user, 'sanctum') - ->postJson('/api/resources', [ - 'key' => 'non-resource-model-' . str()->ulid(), - 'name' => 'Non Resource Model', - 'model_type' => Resource::class, - 'type' => 'tenant', - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['model_type']); + $this->assertNotNull($resources->firstWhere('key', 'users')); } - public function test_resource_model_type_accepts_resource_model_classes(): void + public function test_tenant_admin_cannot_list_available_plan_resources(): void { - $user = User::query()->where('email', config('dev.email'))->firstOrFail(); - $modelType = Node::class; + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $this->actingAs($user, 'sanctum') - ->postJson('/api/resources', [ - 'key' => 'valid-resource-' . str()->ulid(), - 'name' => 'Valid Resource', - 'model_type' => $modelType, - 'type' => 'tenant', - ]) - ->assertCreated() - ->assertJsonPath('data.model_type', $modelType); + ->getJson('/api/plans/resources') + ->assertForbidden(); } - public function test_resource_model_type_is_validated_on_update(): void + public function test_resources_are_not_crud_managed_over_api(): void { $user = User::query()->where('email', config('dev.email'))->firstOrFail(); - $resource = Resource::query()->create([ - 'key' => 'updatable-resource-' . str()->ulid(), - 'name' => 'Updatable Resource', - 'model_type' => Node::class, - 'type' => 'tenant', - ]); $this->actingAs($user, 'sanctum') - ->putJson('/api/resources/' . $resource->id, [ + ->postJson('/api/resources', [ + 'key' => 'forbidden-resource-crud-' . str()->ulid(), + 'name' => 'Forbidden Resource CRUD', 'model_type' => Resource::class, + 'type' => 'tenant', + ]) + ->assertNotFound(); + + $this->actingAs($user, 'sanctum') + ->putJson('/api/resources/' . Resource::query()->firstOrFail()->id, [ + 'name' => 'Forbidden update', ]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['model_type']); + ->assertNotFound(); } } diff --git a/packages/core/tests/Feature/TenantAuthorizationTest.php b/packages/core/tests/Feature/TenantAuthorizationTest.php index 52cb2f3..d0e6bc0 100644 --- a/packages/core/tests/Feature/TenantAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantAuthorizationTest.php @@ -2,8 +2,12 @@ namespace Tests\Feature; +use Froxlor\Core\Models\Plan; +use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Models\TenantResourceReservation; use Froxlor\Core\Models\User; +use Illuminate\Support\Facades\DB; use Tests\TestCase; class TenantAuthorizationTest extends TestCase @@ -77,4 +81,247 @@ public function test_tenant_tree_scopes_filter_root_children_and_tree(): void $this->assertTrue($descendantTreeIds->contains($childTenant->id)); $this->assertTrue($descendantTreeIds->contains($grandchildTenant->id)); } + + public function test_child_tenant_creation_reserves_parent_budget(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id]); + $resourceKey = 'reservation-test-' . str()->ulid(); + $resource = Resource::query()->create([ + 'key' => $resourceKey, + 'name' => 'Reservation Test Resource', + 'model_type' => Tenant::class, + 'type' => 'tenant', + ]); + $tenant->plan->resources()->attach($resource, ['limit' => 2]); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Reserved Child Tenant Plan ' . str()->ulid(), + ]); + $plan->resources()->attach($resource, ['limit' => 2]); + + $childTenantId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants', [ + 'parent_tenant_id' => $tenant->id, + 'plan_id' => $plan->id, + 'name' => 'Reserved Child Tenant ' . str()->ulid(), + ]) + ->assertCreated() + ->json('data.id'); + + $this->assertDatabaseHas('tenant_resource_reservations', [ + 'tenant_id' => $tenant->id, + 'reserved_for_tenant_id' => $childTenantId, + 'plan_id' => $plan->id, + 'resource_key' => $resourceKey, + 'resource_type' => 'tenant', + 'limit' => 2, + ]); + } + + public function test_child_tenant_creation_reserves_environment_resource_budget(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id]); + $resourceKey = 'environment-reservation-test-' . str()->ulid(); + $resource = Resource::query()->create([ + 'key' => $resourceKey, + 'name' => 'Environment Reservation Test Resource', + 'model_type' => Tenant::class, + 'type' => 'environment', + ]); + $tenant->plan->resources()->attach($resource, ['limit' => 2]); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Reserved Child Environment Plan ' . str()->ulid(), + ]); + $plan->resources()->attach($resource, ['limit' => 2]); + + $childTenantId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants', [ + 'parent_tenant_id' => $tenant->id, + 'plan_id' => $plan->id, + 'name' => 'Reserved Child Environment Tenant ' . str()->ulid(), + ]) + ->assertCreated() + ->json('data.id'); + + $this->assertDatabaseHas('tenant_resource_reservations', [ + 'tenant_id' => $tenant->id, + 'reserved_for_tenant_id' => $childTenantId, + 'plan_id' => $plan->id, + 'resource_key' => $resourceKey, + 'resource_type' => 'environment', + 'limit' => 2, + ]); + } + + public function test_child_tenant_creation_rejects_plan_above_available_parent_budget(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id]); + $resourceKey = 'reservation-test-' . str()->ulid(); + $resource = Resource::query()->create([ + 'key' => $resourceKey, + 'name' => 'Reservation Test Resource', + 'model_type' => Tenant::class, + 'type' => 'tenant', + ]); + $tenant->plan->resources()->attach($resource, ['limit' => 2]); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Exhausting Child Tenant Plan ' . str()->ulid(), + ]); + $plan->resources()->attach($resource, ['limit' => 2]); + + TenantResourceReservation::query()->create([ + 'tenant_id' => $tenant->id, + 'reserved_for_tenant_id' => Tenant::query()->where('name', 'Kunde #2')->firstOrFail()->id, + 'plan_id' => $plan->id, + 'resource_key' => $resourceKey, + 'resource_type' => 'tenant', + 'limit' => 1, + ]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants', [ + 'parent_tenant_id' => $tenant->id, + 'plan_id' => $plan->id, + 'name' => 'Rejected Reserved Child Tenant ' . str()->ulid(), + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + } + + public function test_child_tenant_creation_requires_parent_owned_plan(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $globalPlan = Plan::query()->whereNull('tenant_id')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants', [ + 'parent_tenant_id' => $tenant->id, + 'plan_id' => $globalPlan->id, + 'name' => 'Rejected Global Plan Child Tenant ' . str()->ulid(), + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + } + + public function test_child_tenant_plan_update_syncs_reservations(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id]); + $resource = Resource::query()->create([ + 'key' => 'tenant-plan-switch-' . str()->ulid(), + 'name' => 'Tenant Plan Switch Resource', + 'model_type' => Tenant::class, + 'type' => 'tenant', + ]); + $tenant->plan->resources()->attach($resource, ['limit' => 3]); + $initialPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Initial Tenant Switch Plan ' . str()->ulid(), + ]); + $initialPlan->resources()->attach($resource, ['limit' => 1]); + $updatedPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Updated Tenant Switch Plan ' . str()->ulid(), + ]); + $updatedPlan->resources()->attach($resource, ['limit' => 2]); + + $childTenantId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants', [ + 'parent_tenant_id' => $tenant->id, + 'plan_id' => $initialPlan->id, + 'name' => 'Tenant Plan Switch Child ' . str()->ulid(), + ]) + ->assertCreated() + ->json('data.id'); + + $this->actingAs($user, 'sanctum') + ->putJson('/api/tenants/' . $childTenantId, [ + 'plan_id' => $updatedPlan->id, + ]) + ->assertOk(); + + $this->assertDatabaseHas('tenant_resource_reservations', [ + 'tenant_id' => $tenant->id, + 'reserved_for_tenant_id' => $childTenantId, + 'plan_id' => $updatedPlan->id, + 'resource_key' => $resource->key, + 'resource_type' => 'tenant', + 'limit' => 2, + ]); + $this->assertDatabaseMissing('tenant_resource_reservations', [ + 'tenant_id' => $tenant->id, + 'reserved_for_tenant_id' => $childTenantId, + 'plan_id' => $initialPlan->id, + 'resource_key' => $resource->key, + ]); + } + + public function test_child_tenant_plan_update_cannot_drop_below_child_usage(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id]); + $resource = Resource::query()->create([ + 'key' => 'tenant-plan-switch-usage-' . str()->ulid(), + 'name' => 'Tenant Plan Switch Usage Resource', + 'model_type' => Tenant::class, + 'type' => 'tenant', + ]); + $tenant->plan->resources()->attach($resource, ['limit' => 3]); + $initialPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Initial Tenant Usage Switch Plan ' . str()->ulid(), + ]); + $initialPlan->resources()->attach($resource, ['limit' => 2]); + $tooSmallPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Too Small Tenant Usage Switch Plan ' . str()->ulid(), + ]); + $tooSmallPlan->resources()->attach($resource, ['limit' => 1]); + + $childTenantId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants', [ + 'parent_tenant_id' => $tenant->id, + 'plan_id' => $initialPlan->id, + 'name' => 'Tenant Usage Switch Child ' . str()->ulid(), + ]) + ->assertCreated() + ->json('data.id'); + + DB::table('tenant_usage')->insert([ + 'id' => (string)str()->ulid(), + 'tenant_id' => $childTenantId, + 'user_id' => $user->id, + 'resource_key' => $resource->key, + 'resource_id' => (string)str()->ulid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('tenant_usage')->insert([ + 'id' => (string)str()->ulid(), + 'tenant_id' => $childTenantId, + 'user_id' => $user->id, + 'resource_key' => $resource->key, + 'resource_id' => (string)str()->ulid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs($user, 'sanctum') + ->putJson('/api/tenants/' . $childTenantId, [ + 'plan_id' => $tooSmallPlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + } } diff --git a/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php index d519e20..1968b9a 100644 --- a/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php @@ -5,8 +5,10 @@ use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Plan; +use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; +use Illuminate\Support\Facades\DB; use Tests\Fakes\FakeNodeAdapter; use Tests\TestCase; @@ -21,6 +23,10 @@ protected function setUp(): void if (!in_array(FakeNodeAdapter::class, Node::adapters(), true)) { Node::registerAdapter(FakeNodeAdapter::class); } + + Tenant::query() + ->where('name', 'First customer') + ->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Unlimited')->firstOrFail()->id]); } public function test_tenant_admin_can_manage_environment(): void @@ -161,21 +167,15 @@ public function test_tenant_admin_cannot_update_environment_to_unavailable_node( ->assertJsonValidationErrors(['node_id']); } - public function test_tenant_admin_cannot_assign_foreign_or_wrong_type_environment_plan(): void + public function test_tenant_admin_cannot_assign_foreign_environment_plan(): void { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $otherTenant = Tenant::query()->where('name', 'Kunde #2')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $foreignPlan = Plan::query()->create([ 'tenant_id' => $otherTenant->id, - 'type' => 'environment', 'name' => 'Foreign Environment Plan ' . str()->ulid(), ]); - $wrongTypePlan = Plan::query()->create([ - 'tenant_id' => $tenant->id, - 'type' => 'tenant', - 'name' => 'Wrong Type Environment Plan ' . str()->ulid(), - ]); $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/environments', [ @@ -184,17 +184,9 @@ public function test_tenant_admin_cannot_assign_foreign_or_wrong_type_environmen ]) ->assertUnprocessable() ->assertJsonValidationErrors(['plan_id']); - - $this->actingAs($user, 'sanctum') - ->postJson('/api/tenants/' . $tenant->id . '/environments', [ - 'name' => 'Rejected Wrong Type Plan Environment ' . str()->ulid(), - 'plan_id' => $wrongTypePlan->id, - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['plan_id']); } - public function test_tenant_admin_cannot_update_environment_to_foreign_or_wrong_type_environment_plan(): void + public function test_tenant_admin_cannot_update_environment_to_foreign_environment_plan(): void { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $otherTenant = Tenant::query()->where('name', 'Kunde #2')->firstOrFail(); @@ -205,14 +197,8 @@ public function test_tenant_admin_cannot_update_environment_to_foreign_or_wrong_ $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $foreignPlan = Plan::query()->create([ 'tenant_id' => $otherTenant->id, - 'type' => 'environment', 'name' => 'Foreign Update Environment Plan ' . str()->ulid(), ]); - $wrongTypePlan = Plan::query()->create([ - 'tenant_id' => $tenant->id, - 'type' => 'tenant', - 'name' => 'Wrong Update Type Environment Plan ' . str()->ulid(), - ]); $this->actingAs($user, 'sanctum') ->putJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id, [ @@ -220,29 +206,31 @@ public function test_tenant_admin_cannot_update_environment_to_foreign_or_wrong_ ]) ->assertUnprocessable() ->assertJsonValidationErrors(['plan_id']); - - $this->actingAs($user, 'sanctum') - ->putJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id, [ - 'plan_id' => $wrongTypePlan->id, - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['plan_id']); } public function test_tenant_admin_can_assign_available_environment_plan_on_create_and_update(): void { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $resource = Resource::query()->create([ + 'key' => 'environment-plan-switch-usage-' . str()->ulid(), + 'name' => 'Environment Plan Switch Usage Resource', + 'model_type' => Environment::class, + 'type' => 'environment', + ]); + $tenant->plan->resources()->syncWithoutDetaching([ + $resource->id => ['limit' => 1], + ]); $tenantPlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'environment', 'name' => 'Available Environment Plan ' . str()->ulid(), ]); + $tenantPlan->resources()->attach($resource, ['limit' => 1]); $globalPlan = Plan::query()->create([ 'tenant_id' => null, - 'type' => 'environment', 'name' => 'Global Available Environment Plan ' . str()->ulid(), ]); + $globalPlan->resources()->attach($resource, ['limit' => 1]); $environmentId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/environments', [ @@ -260,4 +248,99 @@ public function test_tenant_admin_can_assign_available_environment_plan_on_creat ->assertOk() ->assertJsonPath('data.plan_id', $globalPlan->id); } + + public function test_tenant_admin_cannot_assign_environment_plan_above_tenant_plan(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id]); + $resource = Resource::query()->create([ + 'key' => 'environment-plan-usage-' . str()->ulid(), + 'name' => 'Environment Plan Usage Resource', + 'model_type' => Environment::class, + 'type' => 'environment', + ]); + $tenant->plan->resources()->syncWithoutDetaching([ + $resource->id => ['limit' => 1], + ]); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Oversized Environment Plan ' . str()->ulid(), + ]); + $plan->resources()->attach($resource, ['limit' => 2]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/environments', [ + 'name' => 'Rejected Oversized Environment Plan ' . str()->ulid(), + 'plan_id' => $plan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + } + + public function test_tenant_admin_cannot_update_environment_plan_below_current_usage(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $environment = Environment::query() + ->where('tenant_id', $tenant->id) + ->where('name', 'Kunden Environment') + ->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $resource = Resource::query()->create([ + 'key' => 'environment-plan-usage-' . str()->ulid(), + 'name' => 'Environment Plan Usage Resource', + 'model_type' => Environment::class, + 'type' => 'environment', + ]); + $environmentUserResource = Resource::query()->where('type', 'environment')->where('key', 'users')->firstOrFail(); + $tenantUserResource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $nodeResource = Resource::query()->where('type', 'tenant')->where('key', 'nodes')->firstOrFail(); + $tenantPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment Usage Tenant Plan ' . str()->ulid(), + ]); + $tenantPlan->resources()->attach($resource, ['limit' => 3]); + $tenantPlan->resources()->attach($environmentUserResource, ['limit' => 10]); + $tenantPlan->resources()->attach($tenantUserResource, ['limit' => 10]); + $tenantPlan->resources()->attach($nodeResource, ['limit' => 10]); + $tenant->update(['plan_id' => $tenantPlan->id]); + $initialPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment Usage Initial Plan ' . str()->ulid(), + ]); + $initialPlan->resources()->attach($resource, ['limit' => 2]); + $initialPlan->resources()->attach($environmentUserResource, ['limit' => 10]); + $tooSmallPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment Usage Too Small Plan ' . str()->ulid(), + ]); + $tooSmallPlan->resources()->attach($resource, ['limit' => 1]); + $environment->update(['plan_id' => $initialPlan->id]); + + DB::table('env_usage')->insert([ + 'id' => (string)str()->ulid(), + 'environment_id' => $environment->id, + 'user_id' => $user->id, + 'resource_key' => $resource->key, + 'resource_id' => (string)str()->ulid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('env_usage')->insert([ + 'id' => (string)str()->ulid(), + 'environment_id' => $environment->id, + 'user_id' => $user->id, + 'resource_key' => $resource->key, + 'resource_id' => (string)str()->ulid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs($user, 'sanctum') + ->putJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id, [ + 'plan_id' => $tooSmallPlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + } } diff --git a/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php deleted file mode 100644 index 3c25ca4..0000000 --- a/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php +++ /dev/null @@ -1,89 +0,0 @@ -where('name', 'First customer')->firstOrFail(); - $environment = Environment::query() - ->where('tenant_id', $tenant->id) - ->where('name', 'Kunden Environment') - ->firstOrFail(); - $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); - - $planId = $this->actingAs($user, 'sanctum') - ->postJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/plans', [ - 'name' => 'Environment Policy Test Plan ' . str()->ulid(), - 'description' => 'Created by TenantEnvironmentPlanAuthorizationTest', - ]) - ->assertCreated() - ->json('data.id'); - - $this->actingAs($user, 'sanctum') - ->getJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/plans') - ->assertOk(); - - $this->actingAs($user, 'sanctum') - ->getJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/plans/' . $planId) - ->assertOk(); - - $this->actingAs($user, 'sanctum') - ->putJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/plans/' . $planId, [ - 'description' => 'Updated by TenantEnvironmentPlanAuthorizationTest', - ]) - ->assertOk(); - - $this->actingAs($user, 'sanctum') - ->deleteJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/plans/' . $planId) - ->assertNoContent(); - } - - public function test_unassigned_user_cannot_manage_environment_plan(): void - { - $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); - $environment = Environment::query() - ->where('tenant_id', $tenant->id) - ->where('name', 'Kunden Environment') - ->firstOrFail(); - $user = User::query()->where('email', 'dev3@froxlor.org')->firstOrFail(); - $plan = Plan::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Forbidden Environment Policy Test Plan ' . str()->ulid(), - 'type' => 'environment', - ]); - - $basePath = '/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/plans'; - - $this->actingAs($user, 'sanctum') - ->getJson($basePath) - ->assertForbidden(); - - $this->actingAs($user, 'sanctum') - ->getJson($basePath . '/' . $plan->id) - ->assertForbidden(); - - $this->actingAs($user, 'sanctum') - ->postJson($basePath, [ - 'name' => 'Forbidden Environment Plan ' . str()->ulid(), - ]) - ->assertForbidden(); - - $this->actingAs($user, 'sanctum') - ->putJson($basePath . '/' . $plan->id, [ - 'description' => 'Forbidden update', - ]) - ->assertForbidden(); - - $this->actingAs($user, 'sanctum') - ->deleteJson($basePath . '/' . $plan->id) - ->assertForbidden(); - } -} diff --git a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php index ab0ddd0..3d586f1 100644 --- a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php @@ -3,9 +3,12 @@ namespace Tests\Feature; use Froxlor\Core\Models\Environment; +use Froxlor\Core\Models\Plan; +use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; +use Illuminate\Support\Facades\DB; use Tests\TestCase; class TenantEnvironmentUserAuthorizationTest extends TestCase @@ -118,4 +121,149 @@ public function test_environment_admin_cannot_assign_tenant_role_without_delegat ->assertUnprocessable() ->assertJsonValidationErrors(['tenant_role']); } + + public function test_environment_user_plan_must_stay_within_environment_plan(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $environment = Environment::query() + ->where('tenant_id', $tenant->id) + ->where('name', 'Kunden Environment') + ->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenantRole = Role::query()->where('name', 'Admin')->firstOrFail(); + $environmentRole = Role::query()->where('name', 'Super-Admin')->firstOrFail(); + $resource = Resource::query()->where('key', 'users')->where('type', 'environment')->firstOrFail(); + $basePath = '/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/users'; + + $parentPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment Parent User Plan ' . str()->ulid(), + ]); + $parentPlan->resources()->attach($resource, ['limit' => 2]); + $environment->update(['plan_id' => $parentPlan->id]); + + $validPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment Child User Plan ' . str()->ulid(), + ]); + $validPlan->resources()->attach($resource, ['limit' => 1]); + + $tooLargePlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment Too Large User Plan ' . str()->ulid(), + ]); + $tooLargePlan->resources()->attach($resource, ['limit' => 3]); + $foreignPlan = Plan::query()->create([ + 'tenant_id' => Tenant::query()->where('name', 'Kunde #2')->firstOrFail()->id, + 'name' => 'Foreign Environment User Plan ' . str()->ulid(), + ]); + $foreignPlan->resources()->attach($resource, ['limit' => 1]); + + $this->actingAs($user, 'sanctum') + ->postJson($basePath, [ + 'first_name' => 'Environment', + 'last_name' => 'Plan User', + 'email' => 'environment-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'tenant_role' => $tenantRole->id, + 'environment_role' => $environmentRole->id, + 'environment_plan' => $validPlan->id, + ]) + ->assertCreated(); + + $this->actingAs($user, 'sanctum') + ->postJson($basePath, [ + 'first_name' => 'Forbidden', + 'last_name' => 'Large Environment Plan User', + 'email' => 'environment-large-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'tenant_role' => $tenantRole->id, + 'environment_role' => $environmentRole->id, + 'environment_plan' => $tooLargePlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['environment_plan']); + + $this->actingAs($user, 'sanctum') + ->postJson($basePath, [ + 'first_name' => 'Forbidden', + 'last_name' => 'Foreign Environment Plan User', + 'email' => 'environment-foreign-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'tenant_role' => $tenantRole->id, + 'environment_role' => $environmentRole->id, + 'environment_plan' => $foreignPlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['environment_plan']); + } + + public function test_environment_user_plan_update_cannot_drop_below_current_usage(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $environment = Environment::query() + ->where('tenant_id', $tenant->id) + ->where('name', 'Kunden Environment') + ->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenantRole = Role::query()->where('name', 'Admin')->firstOrFail(); + $environmentRole = Role::query()->where('name', 'Super-Admin')->firstOrFail(); + $resource = Resource::query()->where('key', 'users')->where('type', 'environment')->firstOrFail(); + $basePath = '/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/users'; + $parentPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment User Usage Parent Plan ' . str()->ulid(), + ]); + $parentPlan->resources()->attach($resource, ['limit' => 3]); + $environment->update(['plan_id' => $parentPlan->id]); + $initialPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment User Usage Initial Plan ' . str()->ulid(), + ]); + $initialPlan->resources()->attach($resource, ['limit' => 2]); + $tooSmallPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Environment User Usage Too Small Plan ' . str()->ulid(), + ]); + $tooSmallPlan->resources()->attach($resource, ['limit' => 1]); + + $targetUserId = $this->actingAs($user, 'sanctum') + ->postJson($basePath, [ + 'first_name' => 'Environment', + 'last_name' => 'Usage Plan User', + 'email' => 'environment-usage-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'tenant_role' => $tenantRole->id, + 'environment_role' => $environmentRole->id, + 'environment_plan' => $initialPlan->id, + ]) + ->assertCreated() + ->json('data.id'); + + DB::table('env_usage')->insert([ + 'id' => (string)str()->ulid(), + 'environment_id' => $environment->id, + 'user_id' => $targetUserId, + 'resource_key' => $resource->key, + 'resource_id' => (string)str()->ulid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('env_usage')->insert([ + 'id' => (string)str()->ulid(), + 'environment_id' => $environment->id, + 'user_id' => $targetUserId, + 'resource_key' => $resource->key, + 'resource_id' => (string)str()->ulid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs($user, 'sanctum') + ->putJson($basePath . '/' . $targetUserId, [ + 'environment_plan' => $tooSmallPlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['environment_plan']); + } } diff --git a/packages/core/tests/Feature/TenantNodeAuthorizationTest.php b/packages/core/tests/Feature/TenantNodeAuthorizationTest.php index 0921749..ce80253 100644 --- a/packages/core/tests/Feature/TenantNodeAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantNodeAuthorizationTest.php @@ -101,7 +101,10 @@ public function test_tenant_can_inherit_only_inheritable_nodes_when_creating_sub { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); - $plan = Plan::query()->where('name', 'Everything 10')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Inherited Node Tenant Plan ' . str()->ulid(), + ]); $inheritableNode = Node::query()->create([ 'tenant_id' => $tenant->id, diff --git a/packages/core/tests/Feature/TenantPlanAuthorizationTest.php b/packages/core/tests/Feature/TenantPlanAuthorizationTest.php index 268d457..eb63af1 100644 --- a/packages/core/tests/Feature/TenantPlanAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantPlanAuthorizationTest.php @@ -17,7 +17,6 @@ public function test_tenant_admin_can_manage_tenant_plan(): void $planId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/plans', [ 'name' => 'Tenant Policy Test Plan ' . str()->ulid(), - 'type' => 'tenant', 'description' => 'Created by TenantPlanAuthorizationTest', ]) ->assertCreated() @@ -42,6 +41,26 @@ public function test_tenant_admin_can_manage_tenant_plan(): void ->assertNoContent(); } + public function test_assigned_tenant_plan_cannot_be_deleted(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Assigned Tenant Plan ' . str()->ulid(), + ]); + $originalPlanId = $tenant->plan_id; + + $tenant->update(['plan_id' => $plan->id]); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/tenants/' . $tenant->id . '/plans/' . $plan->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan']); + + $tenant->update(['plan_id' => $originalPlanId]); + } + public function test_unassigned_user_cannot_manage_tenant_plan(): void { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); @@ -49,7 +68,6 @@ public function test_unassigned_user_cannot_manage_tenant_plan(): void $plan = Plan::query()->create([ 'tenant_id' => $tenant->id, 'name' => 'Forbidden Tenant Policy Test Plan ' . str()->ulid(), - 'type' => 'tenant', ]); $this->actingAs($user, 'sanctum') @@ -63,7 +81,6 @@ public function test_unassigned_user_cannot_manage_tenant_plan(): void $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/plans', [ 'name' => 'Forbidden Tenant Plan ' . str()->ulid(), - 'type' => 'tenant', ]) ->assertForbidden(); diff --git a/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php b/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php new file mode 100644 index 0000000..0185d77 --- /dev/null +++ b/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php @@ -0,0 +1,246 @@ +where('name', 'First customer')->firstOrFail(); + $originalPlanId = $tenant->plan_id; + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant Resource Plan ' . str()->ulid(), + ]); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $basePath = '/api/tenants/' . $tenant->id . '/plans/' . $plan->id . '/resources'; + + $this->actingAs($user, 'sanctum') + ->getJson($basePath) + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->postJson($basePath, [ + 'resource_id' => $resource->id, + 'limit' => 1, + ]) + ->assertOk(); + + $this->assertDatabaseHas('audit_logs', [ + 'tenant_id' => $tenant->id, + 'action' => 'resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', + ]); + + $this->actingAs($user, 'sanctum') + ->deleteJson($basePath . '/' . $resource->id) + ->assertOk(); + + $this->assertDatabaseHas('audit_logs', [ + 'tenant_id' => $tenant->id, + 'action' => 'resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', + ]); + } + + public function test_tenant_plan_resource_index_lists_assigned_and_unassigned_resources(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant Resource Listing Plan ' . str()->ulid(), + ]); + $assignedResource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $unassignedResource = Resource::query()->where('type', 'tenant')->where('key', 'roles')->firstOrFail(); + + $plan->resources()->attach($assignedResource, ['limit' => 1]); + + $resources = collect($this->actingAs($user, 'sanctum') + ->getJson('/api/tenants/' . $tenant->id . '/plans/' . $plan->id . '/resources') + ->assertOk() + ->json('data')); + + $assigned = $resources->firstWhere('id', $assignedResource->id); + $unassigned = $resources->firstWhere('id', $unassignedResource->id); + + $this->assertTrue($assigned['assigned']); + $this->assertSame(1, $assigned['limit']); + $this->assertFalse($unassigned['assigned']); + $this->assertSame(0, $unassigned['limit']); + } + + public function test_tenant_plan_resource_route_rejects_foreign_and_global_plans(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $otherTenant = Tenant::query()->where('name', 'Kunde #2')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $foreignPlan = Plan::query()->create([ + 'tenant_id' => $otherTenant->id, + 'name' => 'Foreign Tenant Resource Plan ' . str()->ulid(), + ]); + $globalPlan = Plan::query()->whereNull('tenant_id')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/tenants/' . $tenant->id . '/plans/' . $foreignPlan->id . '/resources') + ->assertForbidden(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/plans/' . $globalPlan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 1, + ]) + ->assertForbidden(); + } + + public function test_tenant_plan_resource_limit_must_not_exceed_tenant_plan(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant->update([ + 'plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id, + ]); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant Resource Limit Plan ' . str()->ulid(), + ]); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 3, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['limit']); + } + + public function test_detaching_unassigned_tenant_plan_resource_returns_validation_error(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Unassigned Tenant Resource Plan ' . str()->ulid(), + ]); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'roles')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/tenants/' . $tenant->id . '/plans/' . $plan->id . '/resources/' . $resource->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['resource_id']); + } + + public function test_assigned_tenant_plan_resources_update_child_reservations(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id]); + $resource = Resource::query()->create([ + 'key' => 'reservation-update-' . str()->ulid(), + 'name' => 'Reservation Update Test Resource', + 'model_type' => Tenant::class, + 'type' => 'tenant', + ]); + $tenant->plan->resources()->attach($resource, ['limit' => 2]); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Assigned Tenant Resource Plan ' . str()->ulid(), + ]); + $plan->resources()->attach($resource, ['limit' => 1]); + $childTenantId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants', [ + 'parent_tenant_id' => $tenant->id, + 'plan_id' => $plan->id, + 'name' => 'Tenant Plan Resource Child ' . str()->ulid(), + ]) + ->assertCreated() + ->json('data.id'); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 2, + ]) + ->assertOk(); + + $this->assertDatabaseHas('tenant_resource_reservations', [ + 'tenant_id' => $tenant->id, + 'reserved_for_tenant_id' => $childTenantId, + 'plan_id' => $plan->id, + 'resource_key' => $resource->key, + 'resource_type' => $resource->type, + 'limit' => 2, + ]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 1, + ]) + ->assertOk(); + + $this->assertDatabaseHas('tenant_resource_reservations', [ + 'tenant_id' => $tenant->id, + 'reserved_for_tenant_id' => $childTenantId, + 'plan_id' => $plan->id, + 'resource_key' => $resource->key, + 'resource_type' => $resource->type, + 'limit' => 1, + ]); + } + + public function test_assigned_tenant_plan_resource_limit_cannot_drop_below_child_usage(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $tenant->update(['plan_id' => Plan::query()->where('name', 'Test Tenant Limited')->firstOrFail()->id]); + $resource = Resource::query()->create([ + 'key' => 'reservation-usage-' . str()->ulid(), + 'name' => 'Reservation Usage Test Resource', + 'model_type' => Tenant::class, + 'type' => 'tenant', + ]); + $tenant->plan->resources()->attach($resource, ['limit' => 2]); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Assigned Tenant Resource Usage Plan ' . str()->ulid(), + ]); + $plan->resources()->attach($resource, ['limit' => 1]); + $childTenantId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants', [ + 'parent_tenant_id' => $tenant->id, + 'plan_id' => $plan->id, + 'name' => 'Tenant Plan Resource Usage Child ' . str()->ulid(), + ]) + ->assertCreated() + ->json('data.id'); + + TenantUsage::query()->create([ + 'tenant_id' => $childTenantId, + 'user_id' => $user->id, + 'resource_key' => $resource->key, + 'resource_id' => User::query()->where('email', 'dev3@froxlor.org')->firstOrFail()->id, + ]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/plans/' . $plan->id . '/resources', [ + 'resource_id' => $resource->id, + 'limit' => 0, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan']); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/tenants/' . $tenant->id . '/plans/' . $plan->id . '/resources/' . $resource->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan']); + } +} diff --git a/packages/core/tests/Feature/TenantUserAuthorizationTest.php b/packages/core/tests/Feature/TenantUserAuthorizationTest.php index 9d3c400..a056316 100644 --- a/packages/core/tests/Feature/TenantUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantUserAuthorizationTest.php @@ -2,9 +2,12 @@ namespace Tests\Feature; +use Froxlor\Core\Models\Plan; +use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\Role; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; +use Illuminate\Support\Facades\DB; use Tests\TestCase; class TenantUserAuthorizationTest extends TestCase @@ -65,6 +68,148 @@ public function test_tenant_admin_can_assign_tenant_owned_role_to_tenant_user(): ->assertCreated(); } + public function test_tenant_user_plan_must_stay_within_tenant_plan(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $role = Role::query()->where('name', 'Admin')->firstOrFail(); + $resource = Resource::query()->where('key', 'users')->where('type', 'tenant')->firstOrFail(); + + $parentPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant Parent User Plan ' . str()->ulid(), + ]); + $parentPlan->resources()->attach($resource, ['limit' => 2]); + $tenant->update(['plan_id' => $parentPlan->id]); + + $validPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant Child User Plan ' . str()->ulid(), + ]); + $validPlan->resources()->attach($resource, ['limit' => 1]); + + $tooLargePlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant Too Large User Plan ' . str()->ulid(), + ]); + $tooLargePlan->resources()->attach($resource, ['limit' => 3]); + + $unlimitedPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant Unlimited User Plan ' . str()->ulid(), + ]); + $unlimitedPlan->resources()->attach($resource, ['limit' => -1]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/users', [ + 'first_name' => 'Tenant', + 'last_name' => 'Plan User', + 'email' => 'tenant-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'role_id' => $role->id, + 'plan_id' => $validPlan->id, + ]) + ->assertCreated(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/users', [ + 'first_name' => 'Forbidden', + 'last_name' => 'Large Plan User', + 'email' => 'tenant-large-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'role_id' => $role->id, + 'plan_id' => $tooLargePlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/users', [ + 'first_name' => 'Forbidden', + 'last_name' => 'Unlimited Plan User', + 'email' => 'tenant-unlimited-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'role_id' => $role->id, + 'plan_id' => $unlimitedPlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/users', [ + 'first_name' => 'Forbidden', + 'last_name' => 'Wrong Type Plan User', + 'email' => 'tenant-wrong-type-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'role_id' => $role->id, + 'plan_id' => Plan::query()->whereNull('tenant_id')->where('name', 'Platform Unlimited')->firstOrFail()->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + } + + public function test_tenant_user_plan_update_cannot_drop_below_current_usage(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $role = Role::query()->where('name', 'Admin')->firstOrFail(); + $resource = Resource::query()->where('key', 'users')->where('type', 'tenant')->firstOrFail(); + $parentPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant User Usage Parent Plan ' . str()->ulid(), + ]); + $parentPlan->resources()->attach($resource, ['limit' => 3]); + $tenant->update(['plan_id' => $parentPlan->id]); + $initialPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant User Usage Initial Plan ' . str()->ulid(), + ]); + $initialPlan->resources()->attach($resource, ['limit' => 2]); + $tooSmallPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tenant User Usage Too Small Plan ' . str()->ulid(), + ]); + $tooSmallPlan->resources()->attach($resource, ['limit' => 1]); + + $targetUserId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/' . $tenant->id . '/users', [ + 'first_name' => 'Tenant', + 'last_name' => 'Usage Plan User', + 'email' => 'tenant-usage-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'role_id' => $role->id, + 'plan_id' => $initialPlan->id, + ]) + ->assertCreated() + ->json('data.id'); + + DB::table('tenant_usage')->insert([ + 'id' => (string)str()->ulid(), + 'tenant_id' => $tenant->id, + 'user_id' => $targetUserId, + 'resource_key' => $resource->key, + 'resource_id' => (string)str()->ulid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('tenant_usage')->insert([ + 'id' => (string)str()->ulid(), + 'tenant_id' => $tenant->id, + 'user_id' => $targetUserId, + 'resource_key' => $resource->key, + 'resource_id' => (string)str()->ulid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs($user, 'sanctum') + ->putJson('/api/tenants/' . $tenant->id . '/users/' . $targetUserId, [ + 'plan_id' => $tooSmallPlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + } + public function test_tenant_admin_cannot_assign_role_from_another_tenant(): void { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); diff --git a/packages/core/tests/Feature/UserAuthorizationTest.php b/packages/core/tests/Feature/UserAuthorizationTest.php index f76a077..e6d1edc 100644 --- a/packages/core/tests/Feature/UserAuthorizationTest.php +++ b/packages/core/tests/Feature/UserAuthorizationTest.php @@ -2,7 +2,10 @@ namespace Tests\Feature; +use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Role; +use Froxlor\Core\Models\Resource; +use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; use Tests\TestCase; @@ -138,4 +141,36 @@ public function test_global_user_create_checks_assigned_role_delegation(): void ->assertUnprocessable() ->assertJsonValidationErrors(['role_id']); } + + public function test_global_user_create_checks_assigned_plan_limits(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $role = Role::query()->where('name', 'Admin')->firstOrFail(); + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); + $parentPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Global User Parent Plan ' . str()->ulid(), + ]); + $parentPlan->resources()->attach($resource, ['limit' => 1]); + $tenant->update(['plan_id' => $parentPlan->id]); + $oversizedPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Global User Oversized Plan ' . str()->ulid(), + ]); + $oversizedPlan->resources()->attach($resource, ['limit' => 2]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/users', [ + 'first_name' => 'Forbidden', + 'last_name' => 'Plan Assignment', + 'email' => 'forbidden-global-plan-assignment-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'tenant_id' => $tenant->id, + 'role_id' => $role->id, + 'plan_id' => $oversizedPlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan_id']); + } } diff --git a/packages/database/.gitignore b/packages/database/.gitignore deleted file mode 100644 index 8a33e12..0000000 --- a/packages/database/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules -/vendor -composer.lock -package-lock.json diff --git a/packages/database/composer.json b/packages/database/composer.json deleted file mode 100644 index 6141d6b..0000000 --- a/packages/database/composer.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "https://getcomposer.org/schema.json", - "name": "froxlor/database", - "type": "library", - "description": "The froxlor database package.", - "keywords": [ - "froxlor", - "management", - "panel" - ], - "homepage": "https://www.froxlor.org", - "license": "proprietary", - "authors": [ - { - "name": "Michael Kaufmann", - "email": "d00p@froxlor.org", - "role": "Lead Developer" - }, - { - "name": "Maurice Preuß", - "email": "envoyr@froxlor.org", - "role": "Developer" - } - ], - "require": { - "php": "^8.5", - "froxlor/core": "*", - "froxlor/ui": "*", - "illuminate/support": "^12.0" - }, - "autoload": { - "psr-4": { - "Froxlor\\Database\\": "src/" - } - }, - "extra": { - "froxlor": { - "type": "package" - }, - "laravel": { - "providers": [ - "Froxlor\\Database\\Providers\\FroxlorDatabaseServiceProvider", - "Froxlor\\Database\\Providers\\EventServiceProvider" - ] - } - } -} diff --git a/packages/database/database/migrations/0002_01_01_000001_create_database_servers_table.php b/packages/database/database/migrations/0002_01_01_000001_create_database_servers_table.php deleted file mode 100644 index 08c1d60..0000000 --- a/packages/database/database/migrations/0002_01_01_000001_create_database_servers_table.php +++ /dev/null @@ -1,45 +0,0 @@ -ulid('id')->primary(); - $table->foreignUlid('node_id')->constrained()->cascadeOnDelete(); - $table->string('name'); - $table->string('driver')->default('mysql'); - $table->string('host')->default('127.0.0.1'); - $table->unsignedInteger('port')->default(3306); - $table->string('admin_username')->nullable(); - $table->text('admin_password')->nullable(); - $table->boolean('supports_per_environment_users')->default(true); - $table->unsignedInteger('max_databases')->nullable(); - $table->string('status')->default('defined'); - $table->timestamp('installed_at')->nullable(); - $table->timestamp('configured_at')->nullable(); - $table->timestamp('last_checked_at')->nullable(); - $table->boolean('is_reachable')->default(false); - $table->text('last_error')->nullable(); - $table->json('properties')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->unique('node_id'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('database_servers'); - } -}; diff --git a/packages/database/database/migrations/0002_01_01_000002_create_databases_table.php b/packages/database/database/migrations/0002_01_01_000002_create_databases_table.php deleted file mode 100644 index 4cc4e98..0000000 --- a/packages/database/database/migrations/0002_01_01_000002_create_databases_table.php +++ /dev/null @@ -1,41 +0,0 @@ -ulid('id')->primary(); - $table->foreignUlid('environment_id')->constrained()->cascadeOnDelete(); - $table->foreignUlid('database_server_id')->nullable()->constrained('database_servers')->nullOnDelete(); - $table->string('name'); - $table->string('database_name')->nullable(); - $table->string('username')->nullable(); - $table->text('password')->nullable(); - $table->string('engine')->default('mysql'); - $table->string('charset')->default('utf8mb4'); - $table->string('collation')->default('utf8mb4_unicode_ci'); - $table->string('status')->default('draft'); - $table->timestamp('provisioned_at')->nullable(); - $table->text('last_error')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->index(['environment_id', 'status']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('databases'); - } -}; diff --git a/packages/database/resources/views/scripts/database-service/configure/mariadb/debian13.blade.php b/packages/database/resources/views/scripts/database-service/configure/mariadb/debian13.blade.php deleted file mode 100644 index 3c1d99c..0000000 --- a/packages/database/resources/views/scripts/database-service/configure/mariadb/debian13.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -mkdir -p /etc/mysql/mariadb.conf.d - -cat > /etc/mysql/mariadb.conf.d/60-froxlor.cnf <<'EOF' -[mysqld] -bind-address = {{ $databaseServer->host }} -port = {{ $databaseServer->port }} -skip-name-resolve - -[client] -host = {{ $databaseServer->host }} -port = {{ $databaseServer->port }} -user = {{ $databaseServer->admin_username ?? 'root' }} -EOF diff --git a/packages/database/resources/views/scripts/database-service/configure/mariadb/ubuntu2404.blade.php b/packages/database/resources/views/scripts/database-service/configure/mariadb/ubuntu2404.blade.php deleted file mode 100644 index 3c1d99c..0000000 --- a/packages/database/resources/views/scripts/database-service/configure/mariadb/ubuntu2404.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -mkdir -p /etc/mysql/mariadb.conf.d - -cat > /etc/mysql/mariadb.conf.d/60-froxlor.cnf <<'EOF' -[mysqld] -bind-address = {{ $databaseServer->host }} -port = {{ $databaseServer->port }} -skip-name-resolve - -[client] -host = {{ $databaseServer->host }} -port = {{ $databaseServer->port }} -user = {{ $databaseServer->admin_username ?? 'root' }} -EOF diff --git a/packages/database/resources/views/scripts/database-service/configure/mysql/debian13.blade.php b/packages/database/resources/views/scripts/database-service/configure/mysql/debian13.blade.php deleted file mode 100644 index 8a1eb39..0000000 --- a/packages/database/resources/views/scripts/database-service/configure/mysql/debian13.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -mkdir -p /etc/mysql/mysql.conf.d - -cat > /etc/mysql/mysql.conf.d/60-froxlor.cnf <<'EOF' -[mysqld] -bind-address = {{ $databaseServer->host }} -port = {{ $databaseServer->port }} -skip-name-resolve - -[client] -host = {{ $databaseServer->host }} -port = {{ $databaseServer->port }} -user = {{ $databaseServer->admin_username ?? 'root' }} -EOF diff --git a/packages/database/resources/views/scripts/database-service/configure/mysql/ubuntu2404.blade.php b/packages/database/resources/views/scripts/database-service/configure/mysql/ubuntu2404.blade.php deleted file mode 100644 index 8a1eb39..0000000 --- a/packages/database/resources/views/scripts/database-service/configure/mysql/ubuntu2404.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -mkdir -p /etc/mysql/mysql.conf.d - -cat > /etc/mysql/mysql.conf.d/60-froxlor.cnf <<'EOF' -[mysqld] -bind-address = {{ $databaseServer->host }} -port = {{ $databaseServer->port }} -skip-name-resolve - -[client] -host = {{ $databaseServer->host }} -port = {{ $databaseServer->port }} -user = {{ $databaseServer->admin_username ?? 'root' }} -EOF diff --git a/packages/database/resources/views/scripts/database-service/configure/pgsql/debian13.blade.php b/packages/database/resources/views/scripts/database-service/configure/pgsql/debian13.blade.php deleted file mode 100644 index ec3bf77..0000000 --- a/packages/database/resources/views/scripts/database-service/configure/pgsql/debian13.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -mkdir -p /etc/postgresql/16/main/conf.d - -cat > /etc/postgresql/16/main/conf.d/60-froxlor.conf <<'EOF' -listen_addresses = '{{ $databaseServer->host }}' -port = {{ $databaseServer->port }} -EOF diff --git a/packages/database/resources/views/scripts/database-service/configure/pgsql/ubuntu2404.blade.php b/packages/database/resources/views/scripts/database-service/configure/pgsql/ubuntu2404.blade.php deleted file mode 100644 index ec3bf77..0000000 --- a/packages/database/resources/views/scripts/database-service/configure/pgsql/ubuntu2404.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -mkdir -p /etc/postgresql/16/main/conf.d - -cat > /etc/postgresql/16/main/conf.d/60-froxlor.conf <<'EOF' -listen_addresses = '{{ $databaseServer->host }}' -port = {{ $databaseServer->port }} -EOF diff --git a/packages/database/resources/views/scripts/database-service/install/mariadb/debian13.blade.php b/packages/database/resources/views/scripts/database-service/install/mariadb/debian13.blade.php deleted file mode 100644 index 6ea8102..0000000 --- a/packages/database/resources/views/scripts/database-service/install/mariadb/debian13.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y mariadb-server mariadb-client -systemctl enable mariadb -systemctl start mariadb diff --git a/packages/database/resources/views/scripts/database-service/install/mariadb/ubuntu2404.blade.php b/packages/database/resources/views/scripts/database-service/install/mariadb/ubuntu2404.blade.php deleted file mode 100644 index 6ea8102..0000000 --- a/packages/database/resources/views/scripts/database-service/install/mariadb/ubuntu2404.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y mariadb-server mariadb-client -systemctl enable mariadb -systemctl start mariadb diff --git a/packages/database/resources/views/scripts/database-service/install/mysql/debian13.blade.php b/packages/database/resources/views/scripts/database-service/install/mysql/debian13.blade.php deleted file mode 100644 index c88212e..0000000 --- a/packages/database/resources/views/scripts/database-service/install/mysql/debian13.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y default-mysql-server default-mysql-client -systemctl enable mysql || systemctl enable mysqld -systemctl start mysql || systemctl start mysqld diff --git a/packages/database/resources/views/scripts/database-service/install/mysql/ubuntu2404.blade.php b/packages/database/resources/views/scripts/database-service/install/mysql/ubuntu2404.blade.php deleted file mode 100644 index c88212e..0000000 --- a/packages/database/resources/views/scripts/database-service/install/mysql/ubuntu2404.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y default-mysql-server default-mysql-client -systemctl enable mysql || systemctl enable mysqld -systemctl start mysql || systemctl start mysqld diff --git a/packages/database/resources/views/scripts/database-service/install/pgsql/debian13.blade.php b/packages/database/resources/views/scripts/database-service/install/pgsql/debian13.blade.php deleted file mode 100644 index e961d28..0000000 --- a/packages/database/resources/views/scripts/database-service/install/pgsql/debian13.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y postgresql postgresql-client -systemctl enable postgresql -systemctl start postgresql diff --git a/packages/database/resources/views/scripts/database-service/install/pgsql/ubuntu2404.blade.php b/packages/database/resources/views/scripts/database-service/install/pgsql/ubuntu2404.blade.php deleted file mode 100644 index e961d28..0000000 --- a/packages/database/resources/views/scripts/database-service/install/pgsql/ubuntu2404.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y postgresql postgresql-client -systemctl enable postgresql -systemctl start postgresql diff --git a/packages/database/routes/api.php b/packages/database/routes/api.php deleted file mode 100644 index 1f880c2..0000000 --- a/packages/database/routes/api.php +++ /dev/null @@ -1,27 +0,0 @@ -prefix('api')->name('api.')->group(function () { - Route::get('nodes/{node}/database-service', [Api\Node\DatabaseServiceController::class, 'show']) - ->name('nodes.database-service.show'); - Route::post('nodes/{node}/database-service', [Api\Node\DatabaseServiceController::class, 'store']) - ->name('nodes.database-service.store'); - Route::put('nodes/{node}/database-service', [Api\Node\DatabaseServiceController::class, 'update']) - ->name('nodes.database-service.update'); - Route::patch('nodes/{node}/database-service', [Api\Node\DatabaseServiceController::class, 'update']) - ->name('nodes.database-service.patch'); - Route::post('nodes/{node}/database-service/install', [Api\Node\DatabaseServiceController::class, 'install']) - ->name('nodes.database-service.install'); - Route::post('nodes/{node}/database-service/configure', [Api\Node\DatabaseServiceController::class, 'configure']) - ->name('nodes.database-service.configure'); - Route::post('nodes/{node}/database-service/check', [Api\Node\DatabaseServiceController::class, 'check']) - ->name('nodes.database-service.check'); - Route::delete('nodes/{node}/database-service', [Api\Node\DatabaseServiceController::class, 'destroy']) - ->name('nodes.database-service.destroy'); - - Route::apiResource('tenants.environments.databases', Api\Tenant\Environment\DatabaseController::class) - ->scoped() - ->names('tenants.environments.databases'); -}); diff --git a/packages/database/routes/web.php b/packages/database/routes/web.php deleted file mode 100644 index 8235013..0000000 --- a/packages/database/routes/web.php +++ /dev/null @@ -1,27 +0,0 @@ -group(function () { - Route::prefix('resources/nodes/{node}')->name('resources.nodes.')->group(function () { - Route::get('database-service', [Web\Node\DatabaseServiceController::class, 'show']) - ->name('database-service.show'); - Route::get('database-service/create', [Web\Node\DatabaseServiceController::class, 'create']) - ->name('database-service.create'); - Route::get('database-service/edit', [Web\Node\DatabaseServiceController::class, 'edit']) - ->name('database-service.edit'); - Route::post('database-service/install', [Web\Node\DatabaseServiceController::class, 'install']) - ->name('database-service.install'); - Route::post('database-service/configure', [Web\Node\DatabaseServiceController::class, 'configure']) - ->name('database-service.configure'); - Route::post('database-service/check', [Web\Node\DatabaseServiceController::class, 'check']) - ->name('database-service.check'); - }); - - Route::resource('tenants.environments.databases', Web\Tenant\Environment\DatabaseController::class) - ->scoped() - ->only(['index', 'create', 'show', 'edit']) - ->names('tenants.environments.databases'); -}); diff --git a/packages/database/src/Events/Database/DatabaseCreated.php b/packages/database/src/Events/Database/DatabaseCreated.php deleted file mode 100644 index 26aadcd..0000000 --- a/packages/database/src/Events/Database/DatabaseCreated.php +++ /dev/null @@ -1,18 +0,0 @@ -databaseServer, 404); - - return Response::jsonResource($node->databaseServer->load('databases')); - } - - public function store(StoreDatabaseServerRequest $request, Node $node) - { - abort_if($node->databaseServer()->exists(), 422, 'Database service already configured for this node.'); - - $databaseService = $node->databaseServer()->create($this->normalizedData($request->validated())); - - return Response::jsonResource($databaseService); - } - - public function update(UpdateDatabaseServerRequest $request, Node $node) - { - $databaseService = $node->databaseServer; - - abort_if(! $databaseService, 404); - - $databaseService->update($this->normalizedData($request->validated(), false)); - - return Response::jsonResource($databaseService->fresh()); - } - - public function install(Request $request, Node $node) - { - $databaseService = $this->databaseService($node); - $databaseService->forceFill([ - 'status' => 'install_queued', - 'last_error' => null, - ])->save(); - - dispatch(new InstallDatabaseService($databaseService->fresh())); - - return Response::jsonResource($databaseService->fresh()); - } - - public function configure(Request $request, Node $node) - { - $databaseService = $this->databaseService($node); - $databaseService->forceFill([ - 'status' => 'configure_queued', - 'last_error' => null, - ])->save(); - - dispatch(new ConfigureDatabaseService($databaseService->fresh())); - - return Response::jsonResource($databaseService->fresh()); - } - - public function check(Request $request, Node $node) - { - $databaseService = $this->databaseService($node); - $databaseService->forceFill([ - 'status' => 'check_queued', - 'last_error' => null, - ])->save(); - - dispatch(new CheckDatabaseService($databaseService->fresh())); - - return Response::jsonResource($databaseService->fresh()); - } - - public function destroy(Request $request, Node $node) - { - $databaseService = $this->databaseService($node); - - $databaseService->delete(); - - return response()->noContent(); - } - - private function normalizedData(array $data, bool $withDefaults = true): array - { - if ($withDefaults) { - $data['driver'] = $data['driver'] ?? 'mysql'; - $data['status'] = $data['status'] ?? 'defined'; - $data['supports_per_environment_users'] = $data['supports_per_environment_users'] ?? true; - } - - return $data; - } - - private function databaseService(Node $node) - { - abort_if(! $node->databaseServer, 404); - - return $node->databaseServer; - } -} diff --git a/packages/database/src/Http/Controllers/Api/Tenant/Environment/DatabaseController.php b/packages/database/src/Http/Controllers/Api/Tenant/Environment/DatabaseController.php deleted file mode 100644 index 5853847..0000000 --- a/packages/database/src/Http/Controllers/Api/Tenant/Environment/DatabaseController.php +++ /dev/null @@ -1,106 +0,0 @@ -assertEnvironmentBelongsToTenant($tenant, $environment); - - return Response::jsonResourceCollection( - $environment->databases()->with('databaseServer') - ); - } - - public function store(StoreDatabaseRequest $request, Tenant $tenant, Environment $environment) - { - $this->assertEnvironmentBelongsToTenant($tenant, $environment); - - $database = $environment->databases()->create( - $this->normalizedData($environment, $request->validated()) - ); - - return Response::jsonResource($database->load('databaseServer')); - } - - public function show(Request $request, Tenant $tenant, Environment $environment, Database $database) - { - $this->assertEnvironmentBelongsToTenant($tenant, $environment); - $this->assertDatabaseBelongsToEnvironment($environment, $database); - - return Response::jsonResource($database->load(['environment', 'databaseServer'])); - } - - public function update(UpdateDatabaseRequest $request, Tenant $tenant, Environment $environment, Database $database) - { - $this->assertEnvironmentBelongsToTenant($tenant, $environment); - $this->assertDatabaseBelongsToEnvironment($environment, $database); - - $database->update($this->normalizedData($environment, $request->validated(), false)); - - return Response::jsonResource($database->fresh()->load('databaseServer')); - } - - public function destroy(Request $request, Tenant $tenant, Environment $environment, Database $database) - { - $this->assertEnvironmentBelongsToTenant($tenant, $environment); - $this->assertDatabaseBelongsToEnvironment($environment, $database); - - $database->delete(); - - return response()->noContent(); - } - - private function assertEnvironmentBelongsToTenant(Tenant $tenant, Environment $environment): void - { - abort_if($environment->tenant_id !== $tenant->id, 404); - } - - private function assertDatabaseBelongsToEnvironment(Environment $environment, Database $database): void - { - abort_if($database->environment_id !== $environment->id, 404); - } - - private function normalizedData(Environment $environment, array $data, bool $withDefaults = true): array - { - [$mainNode, $databaseService] = $this->resolveDatabaseService($environment); - - $data['database_server_id'] = $databaseService->id; - - if ($withDefaults) { - $data['database_name'] = $data['database_name'] ?? $data['name']; - $data['username'] = $data['username'] ?? $data['name']; - $data['engine'] = $data['engine'] ?? 'mysql'; - $data['charset'] = $data['charset'] ?? 'utf8mb4'; - $data['collation'] = $data['collation'] ?? 'utf8mb4_unicode_ci'; - $data['status'] = $data['status'] ?? 'draft'; - } - - return $data; - } - - /** - * @return array{0: Node, 1: DatabaseServer} - */ - private function resolveDatabaseService(Environment $environment): array - { - $mainNode = $environment->nodes()->wherePivot('mode', 'main')->first() ?? $environment->nodes()->first(); - - abort_if(! $mainNode, 422, 'Environment has no node assigned.'); - abort_if(! $mainNode->databaseServer, 422, 'The assigned node has no database service configured.'); - - return [$mainNode, $mainNode->databaseServer]; - } -} diff --git a/packages/database/src/Http/Controllers/Controller.php b/packages/database/src/Http/Controllers/Controller.php deleted file mode 100644 index 6de5985..0000000 --- a/packages/database/src/Http/Controllers/Controller.php +++ /dev/null @@ -1,8 +0,0 @@ -databaseServer) { - return UI::render(DatabaseServiceResource::class, 'create', [$node]); - } - - return UI::render(DatabaseServiceResource::class, 'show', [$node]); - } - - public function create(Node $node) - { - return UI::render(DatabaseServiceResource::class, 'create', [$node]); - } - - public function edit(Node $node) - { - if (! $node->databaseServer) { - return UI::render(DatabaseServiceResource::class, 'create', [$node]); - } - - return UI::render(DatabaseServiceResource::class, 'edit', [$node]); - } - - public function install(Node $node): RedirectResponse - { - abort_if(! $node->databaseServer, 404); - - $node->databaseServer->forceFill([ - 'status' => 'install_queued', - 'last_error' => null, - ])->save(); - - dispatch(new InstallDatabaseService($node->databaseServer->fresh())); - - return redirect()->route('resources.nodes.database-service.show', ['node' => $node]); - } - - public function configure(Node $node): RedirectResponse - { - abort_if(! $node->databaseServer, 404); - - $node->databaseServer->forceFill([ - 'status' => 'configure_queued', - 'last_error' => null, - ])->save(); - - dispatch(new ConfigureDatabaseService($node->databaseServer->fresh())); - - return redirect()->route('resources.nodes.database-service.show', ['node' => $node]); - } - - public function check(Node $node): RedirectResponse - { - abort_if(! $node->databaseServer, 404); - - $node->databaseServer->forceFill([ - 'status' => 'check_queued', - 'last_error' => null, - ])->save(); - - dispatch(new CheckDatabaseService($node->databaseServer->fresh())); - - return redirect()->route('resources.nodes.database-service.show', ['node' => $node]); - } -} diff --git a/packages/database/src/Http/Controllers/Web/Tenant/Environment/DatabaseController.php b/packages/database/src/Http/Controllers/Web/Tenant/Environment/DatabaseController.php deleted file mode 100644 index 78a28f8..0000000 --- a/packages/database/src/Http/Controllers/Web/Tenant/Environment/DatabaseController.php +++ /dev/null @@ -1,34 +0,0 @@ - 'required|string|max:255', - 'database_name' => 'nullable|string|max:255', - 'username' => 'nullable|string|max:255', - 'password' => 'nullable|string|max:255', - 'engine' => 'nullable|string|max:50', - 'charset' => 'nullable|string|max:50', - 'collation' => 'nullable|string|max:100', - 'status' => 'nullable|string|max:50', - ]; - } -} diff --git a/packages/database/src/Http/Requests/StoreDatabaseServerRequest.php b/packages/database/src/Http/Requests/StoreDatabaseServerRequest.php deleted file mode 100644 index df85240..0000000 --- a/packages/database/src/Http/Requests/StoreDatabaseServerRequest.php +++ /dev/null @@ -1,27 +0,0 @@ - 'required|string|max:255', - 'driver' => 'nullable|string|max:50', - 'host' => 'required|string|max:255', - 'port' => 'required|integer|min:1|max:65535', - 'admin_username' => 'nullable|string|max:255', - 'admin_password' => 'nullable|string|max:255', - 'supports_per_environment_users' => 'nullable|boolean', - 'max_databases' => 'nullable|integer|min:1', - ]; - } -} diff --git a/packages/database/src/Http/Requests/UpdateDatabaseRequest.php b/packages/database/src/Http/Requests/UpdateDatabaseRequest.php deleted file mode 100644 index 9d0d5b0..0000000 --- a/packages/database/src/Http/Requests/UpdateDatabaseRequest.php +++ /dev/null @@ -1,28 +0,0 @@ - 'sometimes|string|max:255', - 'database_name' => 'sometimes|nullable|string|max:255', - 'username' => 'sometimes|nullable|string|max:255', - 'password' => 'sometimes|nullable|string|max:255', - 'engine' => 'sometimes|nullable|string|max:50', - 'charset' => 'sometimes|nullable|string|max:50', - 'collation' => 'sometimes|nullable|string|max:100', - 'status' => 'sometimes|nullable|string|max:50', - 'last_error' => 'sometimes|nullable|string', - ]; - } -} diff --git a/packages/database/src/Http/Requests/UpdateDatabaseServerRequest.php b/packages/database/src/Http/Requests/UpdateDatabaseServerRequest.php deleted file mode 100644 index a3164a0..0000000 --- a/packages/database/src/Http/Requests/UpdateDatabaseServerRequest.php +++ /dev/null @@ -1,27 +0,0 @@ - 'sometimes|string|max:255', - 'driver' => 'sometimes|nullable|string|max:50', - 'host' => 'sometimes|string|max:255', - 'port' => 'sometimes|integer|min:1|max:65535', - 'admin_username' => 'sometimes|nullable|string|max:255', - 'admin_password' => 'sometimes|nullable|string|max:255', - 'supports_per_environment_users' => 'sometimes|boolean', - 'max_databases' => 'sometimes|nullable|integer|min:1', - ]; - } -} diff --git a/packages/database/src/Jobs/DatabaseService/CheckDatabaseService.php b/packages/database/src/Jobs/DatabaseService/CheckDatabaseService.php deleted file mode 100644 index fe6f36d..0000000 --- a/packages/database/src/Jobs/DatabaseService/CheckDatabaseService.php +++ /dev/null @@ -1,22 +0,0 @@ -check($this->databaseServer->fresh()); - } -} diff --git a/packages/database/src/Jobs/DatabaseService/ConfigureDatabaseService.php b/packages/database/src/Jobs/DatabaseService/ConfigureDatabaseService.php deleted file mode 100644 index 2e2ce8c..0000000 --- a/packages/database/src/Jobs/DatabaseService/ConfigureDatabaseService.php +++ /dev/null @@ -1,22 +0,0 @@ -configure($this->databaseServer->fresh()); - } -} diff --git a/packages/database/src/Jobs/DatabaseService/InstallDatabaseService.php b/packages/database/src/Jobs/DatabaseService/InstallDatabaseService.php deleted file mode 100644 index 573bce7..0000000 --- a/packages/database/src/Jobs/DatabaseService/InstallDatabaseService.php +++ /dev/null @@ -1,22 +0,0 @@ -install($this->databaseServer->fresh()); - } -} diff --git a/packages/database/src/Listeners/Database/CreateDatabase.php b/packages/database/src/Listeners/Database/CreateDatabase.php deleted file mode 100644 index 6241a44..0000000 --- a/packages/database/src/Listeners/Database/CreateDatabase.php +++ /dev/null @@ -1,17 +0,0 @@ -database); - - // $event->database->environment... - } -} diff --git a/packages/database/src/Listeners/DatabaseServer/CreateDatabaseServer.php b/packages/database/src/Listeners/DatabaseServer/CreateDatabaseServer.php deleted file mode 100644 index 822001c..0000000 --- a/packages/database/src/Listeners/DatabaseServer/CreateDatabaseServer.php +++ /dev/null @@ -1,17 +0,0 @@ -database); - - // $event->database->environment... - } -} diff --git a/packages/database/src/Models/Database.php b/packages/database/src/Models/Database.php deleted file mode 100644 index d5cf6cf..0000000 --- a/packages/database/src/Models/Database.php +++ /dev/null @@ -1,63 +0,0 @@ - 'encrypted', - 'provisioned_at' => 'datetime', - ]; - - public function environment(): BelongsTo - { - return $this->belongsTo(Environment::class); - } - - public function databaseServer(): BelongsTo - { - return $this->belongsTo(DatabaseServer::class); - } - - public function statusLabel(): Attribute - { - return Attribute::make( - get: fn() => str($this->status)->headline()->value(), - ); - } -} diff --git a/packages/database/src/Models/DatabaseServer.php b/packages/database/src/Models/DatabaseServer.php deleted file mode 100644 index 59d8cb6..0000000 --- a/packages/database/src/Models/DatabaseServer.php +++ /dev/null @@ -1,65 +0,0 @@ - $databases - */ -#[ObservedBy(DatabaseServerObserver::class)] -class DatabaseServer extends Model -{ - use HasUlids, SoftDeletes; - - protected $guarded = []; - - protected $hidden = [ - 'admin_password', - ]; - - protected $casts = [ - 'admin_password' => 'encrypted', - 'supports_per_environment_users' => 'boolean', - 'installed_at' => 'datetime', - 'configured_at' => 'datetime', - 'last_checked_at' => 'datetime', - 'is_reachable' => 'boolean', - 'properties' => 'encrypted:array', - ]; - - public function databases(): HasMany - { - return $this->hasMany(Database::class); - } - - public function node(): BelongsTo - { - return $this->belongsTo(Node::class); - } -} diff --git a/packages/database/src/Observers/DatabaseObserver.php b/packages/database/src/Observers/DatabaseObserver.php deleted file mode 100644 index 96698f0..0000000 --- a/packages/database/src/Observers/DatabaseObserver.php +++ /dev/null @@ -1,34 +0,0 @@ -> - */ - protected $listen = []; -} diff --git a/packages/database/src/Providers/FroxlorDatabaseServiceProvider.php b/packages/database/src/Providers/FroxlorDatabaseServiceProvider.php deleted file mode 100644 index 69ed37e..0000000 --- a/packages/database/src/Providers/FroxlorDatabaseServiceProvider.php +++ /dev/null @@ -1,147 +0,0 @@ -loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - - // Routes - $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); - $this->loadRoutesFrom(__DIR__ . '/../../routes/api.php'); - - // Views - $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'froxlor-database'); - - // Relations - $this->extendRelations(); - - // Tenant environment UI extensions - $this->extendUserInterface(); - - // Platform-specific provisioning scripts - $this->registerScripts(); - } - - public function register(): void - { - // - } - - private function extendRelations(): void - { - Environment::resolveRelationUsing('databases', function (Environment $environment) { - return $environment->hasMany(Database::class); - }); - Node::resolveRelationUsing('databaseServer', function (Node $node) { - return $node->hasOne(DatabaseServer::class); - }); - } - - private function extendUserInterface(): void - { - Schema::stack('tenants.environments.show.tabs', function (Environment $environment) { - return Schemas\Components\Tab::make('tenants.environments.show.tabs.databases') - ->sort(1500) - ->label('Databases') - ->components([ - Schemas\Components\Relation::make('databases') - ->fetch(route('api.tenants.environments.databases.index', [ - 'tenant' => $environment->tenant_id, - 'environment' => $environment, - ])) - ->intendedRoute('tenants.environments.databases.show', [ - 'tenant' => $environment->tenant_id, - 'environment' => $environment->id, - 'database' => '{id}', - ]) - ->columns(DatabaseTable::columns()) - ->columnActions(DatabaseTable::columnActions($environment)) - ->actions(DatabaseTable::actions($environment)), - ]); - }); - - Schema::stack('resources.nodes.show.tabs', function (Node $node) { - return Schemas\Components\Tab::make('resources.nodes.show.tabs.database_service') - ->sort(1500) - ->label('Database service') - ->components([ - $node->databaseServer - ? app(DatabaseServiceResource::class)->show($node) - : app(DatabaseServiceResource::class)->create($node), - ]); - }); - } - - private function registerScripts(): void - { - foreach (['debian13' => 'debian@13', 'ubuntu2404' => 'ubuntu@24.04'] as $slug => $platformKey) { - foreach (['mariadb', 'mysql', 'pgsql'] as $driver) { - ScriptRegistry::register(new ScriptDefinition( - feature: 'database-service', - action: 'install', - platformKey: $platformKey, - view: "froxlor-database::scripts.database-service.install.{$driver}.{$slug}", - variant: $driver, - targetPath: "/usr/local/lib/froxlor/database-service/install-{$driver}.sh", - runAsRoot: true, - ownership: ['root', 'root'], - executable: true, - executeAfterWrite: true, - package: 'database', - )); - - ScriptRegistry::register(new ScriptDefinition( - feature: 'database-service', - action: 'configure', - platformKey: $platformKey, - view: "froxlor-database::scripts.database-service.configure.{$driver}.{$slug}", - variant: $driver, - targetPath: "/usr/local/lib/froxlor/database-service/configure-{$driver}.sh", - runAsRoot: true, - ownership: ['root', 'root'], - executable: true, - executeAfterWrite: true, - reloadCommands: $this->reloadCommands($driver), - package: 'database', - )); - } - } - } - - private function reloadCommands(string $driver): array - { - return match ($driver) { - 'pgsql' => [ - 'psql --version', - 'systemctl restart postgresql', - 'systemctl is-active postgresql', - ], - 'mysql' => [ - 'mysql --version', - 'systemctl restart mysql || systemctl restart mysqld', - 'systemctl is-active mysql || systemctl is-active mysqld', - ], - default => [ - 'mariadb --version || mysql --version', - 'systemctl restart mariadb', - 'systemctl is-active mariadb', - ], - }; - } -} diff --git a/packages/database/src/Resources/Nodes/DatabaseServiceResource.php b/packages/database/src/Resources/Nodes/DatabaseServiceResource.php deleted file mode 100644 index 28ee114..0000000 --- a/packages/database/src/Resources/Nodes/DatabaseServiceResource.php +++ /dev/null @@ -1,69 +0,0 @@ -teaser(trans('froxlor-core::generic.node')) - ->title($node->name . ' - Database service') - ->description(trans('froxlor-core::generic.create_resource')) - ->push(route('api.nodes.database-service.store', ['node' => $node])) - ->intendedRoute('resources.nodes.database-service.show', ['node' => $node]) - ->components(DatabaseServerForm::schema()) - ->actions([ - Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('resources.nodes.show', ['node' => $node])), - ]); - } - - public function show(Node $node): Schema - { - $databaseService = $node->databaseServer; - - abort_if(! $databaseService, 404); - - return Schema::make('resources.nodes.database-service.show') - ->props([ - 'node' => $node, - 'databaseServer' => $databaseService, - ]) - ->teaser(trans('froxlor-core::generic.node')) - ->title($node->name . ' - Database service') - ->description(trans('froxlor-core::generic.view_resource')) - ->fetch(route('api.nodes.database-service.show', ['node' => $node])) - ->components(DatabaseServerView::schema($node, $databaseService)) - ->actions(DatabaseServerView::actions($node, $databaseService)); - } - - public function edit(Node $node): Schema - { - $databaseService = $node->databaseServer; - - abort_if(! $databaseService, 404); - - return Schema::make('resources.nodes.database-service.edit') - ->teaser(trans('froxlor-core::generic.node')) - ->title($node->name . ' - Database service') - ->description(trans('froxlor-core::generic.edit_resource')) - ->fetch(route('api.nodes.database-service.show', ['node' => $node])) - ->push(route('api.nodes.database-service.update', ['node' => $node]), 'PUT') - ->intendedRoute('resources.nodes.database-service.show', ['node' => $node]) - ->components(DatabaseServerForm::schema()) - ->actions([ - Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('resources.nodes.database-service.show', ['node' => $node])), - ]); - } -} diff --git a/packages/database/src/Resources/Nodes/Relations/DatabaseServers/Schemas/DatabaseServerForm.php b/packages/database/src/Resources/Nodes/Relations/DatabaseServers/Schemas/DatabaseServerForm.php deleted file mode 100644 index 430e77e..0000000 --- a/packages/database/src/Resources/Nodes/Relations/DatabaseServers/Schemas/DatabaseServerForm.php +++ /dev/null @@ -1,54 +0,0 @@ -title('Database server') - ->components([ - Forms\Components\TextInput::make('name') - ->label(trans('froxlor-core::generic.name')) - ->required(), - Forms\Components\Select::make('driver') - ->label(trans('froxlor-core::generic.driver')) - ->options([ - 'mysql' => 'MySQL', - 'mariadb' => 'MariaDB', - 'pgsql' => 'PostgreSQL', - ]), - Forms\Components\TextInput::make('host') - ->label(trans('froxlor-core::generic.hostname')) - ->required(), - Forms\Components\TextInput::make('port') - ->label(trans('froxlor-core::generic.port')) - ->integer() - ->required(), - ]), - Section::make('database_server_access') - ->title(trans('froxlor-core::generic.authentication')) - ->components([ - Forms\Components\TextInput::make('admin_username') - ->label('Admin username'), - Forms\Components\TextInput::make('admin_password') - ->label('Admin password') - ->password(), - Forms\Components\Select::make('supports_per_environment_users') - ->label('Environment users') - ->options([ - 1 => trans('froxlor-core::generic.yes'), - 0 => trans('froxlor-core::generic.no'), - ]), - Forms\Components\TextInput::make('max_databases') - ->label('Max databases') - ->integer(), - ]), - ]; - } -} diff --git a/packages/database/src/Resources/Nodes/Relations/DatabaseServers/Schemas/DatabaseServerView.php b/packages/database/src/Resources/Nodes/Relations/DatabaseServers/Schemas/DatabaseServerView.php deleted file mode 100644 index 8ae7bd6..0000000 --- a/packages/database/src/Resources/Nodes/Relations/DatabaseServers/Schemas/DatabaseServerView.php +++ /dev/null @@ -1,48 +0,0 @@ -label(trans('froxlor-core::generic.name')) - ->default(fn() => $databaseServer->name), - Schemas\Components\Text::make('driver') - ->label('Driver') - ->default(fn() => $databaseServer->driver), - Schemas\Components\Text::make('host') - ->label(trans('froxlor-core::generic.hostname')) - ->default(fn() => $databaseServer->host), - Schemas\Components\Text::make('port') - ->label('Port') - ->default(fn() => (string) $databaseServer->port), - Schemas\Components\Text::make('admin_username') - ->label('Admin username') - ->default(fn() => $databaseServer->admin_username ?: '-'), - ...NodeServiceSchema::standardStatusFields($databaseServer), - Schemas\Components\Text::make('databases_count') - ->label('Databases') - ->default(fn() => (string) $databaseServer->databases()->count()), - ], - ), - ]; - } - - public static function actions(Node $node, DatabaseServer $databaseServer): array - { - return NodeServiceActions::make($node, 'resources.nodes.database-service'); - } -} diff --git a/packages/database/src/Resources/Tenants/Relations/Databases/DatabaseResource.php b/packages/database/src/Resources/Tenants/Relations/Databases/DatabaseResource.php deleted file mode 100644 index 27842a1..0000000 --- a/packages/database/src/Resources/Tenants/Relations/Databases/DatabaseResource.php +++ /dev/null @@ -1,112 +0,0 @@ -title('Databases') - ->description(trans('froxlor-core::generic.show_resource_list', ['resource' => 'databases'])) - ->fetch(route('api.tenants.environments.databases.index', [ - 'tenant' => $tenant, - 'environment' => $environment, - ])) - ->intendedRoute('tenants.environments.databases.show', [ - 'tenant' => $tenant->id, - 'environment' => $environment->id, - 'database' => '{id}', - ]) - ->columns(DatabaseTable::columns()) - ->columnActions(DatabaseTable::columnActions($environment)) - ->actions(DatabaseTable::actions($environment)); - } - - public function create(Tenant $tenant, Environment $environment): Schema - { - return Schema::make() - ->teaser(trans('froxlor-core::generic.tenant') . ' - ' . trans('froxlor-core::generic.environment')) - ->title($environment->name . ' - ' . trans('froxlor-core::generic.create')) - ->description(trans('froxlor-core::generic.create_resource')) - ->push(route('api.tenants.environments.databases.store', [ - 'tenant' => $tenant, - 'environment' => $environment, - ])) - ->intendedRoute('tenants.environments.databases.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'database' => '{id}', - ]) - ->components(DatabaseForm::schema($environment)) - ->actions([ - Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('tenants.environments.show', ['tenant' => $tenant, 'environment' => $environment])), - ]); - } - - public function show(Tenant $tenant, Environment $environment, Database $database): Schema - { - return Schema::make('tenants.environments.databases.show') - ->props([ - 'tenant' => $tenant, - 'environment' => $environment, - 'database' => $database, - ]) - ->teaser(trans('froxlor-core::generic.tenant') . ' - ' . trans('froxlor-core::generic.environment')) - ->title($environment->name . ' - ' . $database->name) - ->description(trans('froxlor-core::generic.view_resource')) - ->fetch(route('api.tenants.environments.databases.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'database' => $database, - ])) - ->components(DatabaseView::schema($tenant, $environment, $database)) - ->actions(DatabaseView::actions($tenant, $environment, $database)); - } - - public function edit(Tenant $tenant, Environment $environment, Database $database): Schema - { - return Schema::make() - ->teaser(trans('froxlor-core::generic.tenant') . ' - ' . trans('froxlor-core::generic.environment')) - ->title($environment->name . ' - ' . $database->name) - ->description(trans('froxlor-core::generic.edit_resource')) - ->fetch(route('api.tenants.environments.databases.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'database' => $database, - ])) - ->push(route('api.tenants.environments.databases.update', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'database' => $database, - ]), 'PUT') - ->intendedRoute('tenants.environments.databases.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'database' => $database, - ]) - ->components(DatabaseForm::schema($environment)) - ->actions([ - Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('tenants.environments.databases.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'database' => $database, - ])), - ]); - } -} diff --git a/packages/database/src/Resources/Tenants/Relations/Databases/Schemas/DatabaseForm.php b/packages/database/src/Resources/Tenants/Relations/Databases/Schemas/DatabaseForm.php deleted file mode 100644 index 336112b..0000000 --- a/packages/database/src/Resources/Tenants/Relations/Databases/Schemas/DatabaseForm.php +++ /dev/null @@ -1,67 +0,0 @@ -title('Database') - ->components([ - Forms\Components\TextInput::make('name') - ->label(trans('froxlor-core::generic.name')) - ->required(), - Forms\Components\TextInput::make('database_name') - ->label('Database name'), - Forms\Components\TextInput::make('username') - ->label(trans('froxlor-core::generic.username')), - Forms\Components\TextInput::make('password') - ->label(trans('froxlor-core::generic.password')) - ->password(), - ]), - Section::make('database_configuration') - ->title(trans('froxlor-core::generic.configuration')) - ->components([ - Forms\Components\Select::make('engine') - ->label('Engine') - ->options([ - 'mysql' => 'MySQL', - 'mariadb' => 'MariaDB', - ]), - Forms\Components\TextInput::make('node_database_service') - ->label('Node database service') - ->default(function () use ($environment) { - $node = $environment->nodes()->wherePivot('mode', 'main')->first() ?? $environment->nodes()->first(); - - if (! $node) { - return 'No node assigned'; - } - - if (! $node->databaseServer) { - return $node->name . ' - no database service configured'; - } - - return $node->name . ' - ' . $node->databaseServer->name; - }) - ->rules(['nullable', 'string']), - Forms\Components\TextInput::make('charset') - ->label('Charset'), - Forms\Components\TextInput::make('collation') - ->label('Collation'), - Forms\Components\Select::make('status') - ->label('Status') - ->options([ - 'draft' => 'Draft', - 'active' => 'Active', - 'error' => 'Error', - ]), - ]), - ]; - } -} diff --git a/packages/database/src/Resources/Tenants/Relations/Databases/Schemas/DatabaseView.php b/packages/database/src/Resources/Tenants/Relations/Databases/Schemas/DatabaseView.php deleted file mode 100644 index cfc7d51..0000000 --- a/packages/database/src/Resources/Tenants/Relations/Databases/Schemas/DatabaseView.php +++ /dev/null @@ -1,63 +0,0 @@ -components([ - Schemas\Components\Tab::make('details') - ->sort(1) - ->label(trans('froxlor-core::generic.details')) - ->components([ - Schemas\Components\Section::make('database_meta') - ->title('Database') - ->components([ - Schemas\Components\Text::make('name') - ->label(trans('froxlor-core::generic.name')) - ->default(fn() => $database->name), - Schemas\Components\Text::make('database_name') - ->label('Database name') - ->default(fn() => $database->database_name ?: $database->name), - Schemas\Components\Text::make('username') - ->label(trans('froxlor-core::generic.username')) - ->default(fn() => $database->username ?: '-'), - Schemas\Components\Text::make('status') - ->label('Status') - ->default(fn() => $database->status_label), - Schemas\Components\Text::make('engine') - ->label('Engine') - ->default(fn() => $database->engine), - ]), - ]), - ]), - ]; - } - - public static function actions(Tenant $tenant, Environment $environment, Database $database): array - { - return [ - Tables\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.backto', ['entity' => $environment->name])) - ->href(route('tenants.environments.show', ['tenant' => $tenant, 'environment' => $environment])) - ->icon('circle-chevron-left'), - Tables\Actions\Action::make('edit') - ->label(trans('froxlor-core::generic.edit')) - ->href(route('tenants.environments.databases.edit', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'database' => $database, - ])) - ->icon('pencil'), - ]; - } -} diff --git a/packages/database/src/Resources/Tenants/Relations/Databases/Tables/DatabaseTable.php b/packages/database/src/Resources/Tenants/Relations/Databases/Tables/DatabaseTable.php deleted file mode 100644 index 5252045..0000000 --- a/packages/database/src/Resources/Tenants/Relations/Databases/Tables/DatabaseTable.php +++ /dev/null @@ -1,69 +0,0 @@ -label(trans('froxlor-core::generic.name')) - ->searchable() - ->sortable(), - Tables\Columns\TextColumn::make('database_name') - ->label('Database name') - ->sortable(), - Tables\Columns\TextColumn::make('username') - ->label(trans('froxlor-core::generic.username')) - ->sortable(), - Tables\Columns\TextColumn::make('status') - ->label('Status') - ->sortable(), - Tables\Columns\TextColumn::make('created_at') - ->label(trans('froxlor-core::generic.created_at')) - ->dateTime() - ->sortable(), - ]; - } - - public static function columnActions(Environment $environment): array - { - return [ - Tables\ColumnActions\Action::make('view') - ->label(trans('froxlor-core::generic.view')) - ->intendedRoute('tenants.environments.databases.show', [ - 'tenant' => $environment->tenant_id, - 'environment' => $environment->id, - 'database' => '{id}', - ]) - ->icon('eye'), - ]; - } - - public static function actions(Environment $environment): array - { - return [ - Tables\Actions\Action::make('create') - ->label(trans('froxlor-core::generic.create')) - ->href(route('tenants.environments.databases.create', [ - 'tenant' => $environment->tenant_id, - 'environment' => $environment, - ])) - ->visible(fn() => self::mainNodeHasDatabaseService($environment)) - ->icon('plus'), - ]; - } - - private static function mainNodeHasDatabaseService(Environment $environment): bool - { - /** @var Node|null $node */ - $node = $environment->nodes()->wherePivot('mode', 'main')->first() ?? $environment->nodes()->first(); - - return $node?->databaseServer !== null; - } -} diff --git a/packages/database/src/Services/DatabaseServiceLifecycle.php b/packages/database/src/Services/DatabaseServiceLifecycle.php deleted file mode 100644 index 225da12..0000000 --- a/packages/database/src/Services/DatabaseServiceLifecycle.php +++ /dev/null @@ -1,208 +0,0 @@ -forceFill([ - 'status' => 'installing', - 'last_error' => null, - ])->save(); - - try { - $adapter = $databaseServer->node->adapter(); - $platform = $databaseServer->node->platform(); - $definition = ScriptRegistry::resolve('database-service', 'install', $databaseServer->node, $databaseServer->driver); - - if (! $platform->supported) { - $this->fail($databaseServer, 'Unsupported platform: ' . $platform->key()); - return; - } - - if (! $definition) { - $this->fail($databaseServer, 'No install script registered for platform ' . $platform->key() . ' and driver ' . $databaseServer->driver); - return; - } - - if (! $adapter->isConnected()) { - $this->fail($databaseServer, 'Unable to connect to node.'); - return; - } - - $probe = $this->binaryProbeCommand($databaseServer->driver); - $version = trim((string) $adapter->exec([$probe])); - $properties = $databaseServer->properties ?? []; - $properties['lifecycle']['install_probe'] = $probe; - $properties['lifecycle']['install_result'] = $version; - $properties['lifecycle']['install_script_view'] = $definition->view; - $installPlan = $this->deployer->plan($definition, [ - 'databaseServer' => $databaseServer, - 'node' => $databaseServer->node, - 'platform' => $platform, - ]); - $properties['lifecycle']['install_plan'] = $installPlan->toArray(); - - if ($version !== '') { - $databaseServer->forceFill([ - 'installed_at' => now(), - 'status' => 'installed', - 'properties' => $properties, - 'last_error' => null, - ])->save(); - - return; - } - - $databaseServer->forceFill([ - 'status' => 'install_pending', - 'properties' => $properties, - 'last_error' => 'Database service binary was not found. Review install deployment plan.', - ])->save(); - } catch (Throwable $exception) { - $this->fail($databaseServer, $exception->getMessage()); - } - } - - public function configure(DatabaseServer $databaseServer): void - { - $databaseServer->forceFill([ - 'status' => 'configuring', - 'last_error' => null, - ])->save(); - - try { - $adapter = $databaseServer->node->adapter(); - $platform = $databaseServer->node->platform(); - $definition = ScriptRegistry::resolve('database-service', 'configure', $databaseServer->node, $databaseServer->driver); - - if (! $platform->supported) { - $this->fail($databaseServer, 'Unsupported platform: ' . $platform->key()); - return; - } - - if (! $definition) { - $this->fail($databaseServer, 'No configure script registered for platform ' . $platform->key() . ' and driver ' . $databaseServer->driver); - return; - } - - if (! $adapter->isConnected()) { - $this->fail($databaseServer, 'Unable to connect to node.'); - return; - } - - if (! $databaseServer->host || ! $databaseServer->port) { - $this->fail($databaseServer, 'Host and port are required for configuration.'); - return; - } - - $properties = $databaseServer->properties ?? []; - $properties['lifecycle']['configured_host'] = $databaseServer->host; - $properties['lifecycle']['configured_port'] = $databaseServer->port; - $properties['lifecycle']['admin_username'] = $databaseServer->admin_username; - $properties['lifecycle']['configure_script_view'] = $definition->view; - $configurePlan = $this->deployer->plan($definition, [ - 'databaseServer' => $databaseServer, - 'node' => $databaseServer->node, - 'platform' => $platform, - ]); - $properties['lifecycle']['configure_plan'] = $configurePlan->toArray(); - - $this->deployer->apply($databaseServer->node, $configurePlan); - - $databaseServer->forceFill([ - 'configured_at' => now(), - 'status' => 'configured', - 'properties' => $properties, - 'last_error' => null, - ])->save(); - } catch (Throwable $exception) { - $this->fail($databaseServer, $exception->getMessage()); - } - } - - public function check(DatabaseServer $databaseServer): void - { - $databaseServer->forceFill([ - 'status' => 'checking', - 'last_error' => null, - ])->save(); - - try { - $adapter = $databaseServer->node->adapter(); - - if (! $adapter->isConnected()) { - $databaseServer->forceFill([ - 'status' => 'unreachable', - 'is_reachable' => false, - 'last_checked_at' => now(), - 'last_error' => 'Unable to connect to node.', - ])->save(); - return; - } - - $serviceProbe = $this->serviceProbeCommand($databaseServer->driver); - $serviceState = trim((string) $adapter->exec([$serviceProbe])); - $version = trim((string) $adapter->exec([$this->binaryProbeCommand($databaseServer->driver)])); - - $properties = $databaseServer->properties ?? []; - $properties['lifecycle']['service_probe'] = $serviceProbe; - $properties['lifecycle']['service_state'] = $serviceState; - $properties['lifecycle']['version'] = $version; - - $isReachable = $serviceState === 'active' || $serviceState === 'running'; - - $payload = [ - 'status' => $isReachable ? 'ready' : 'unreachable', - 'is_reachable' => $isReachable, - 'last_checked_at' => now(), - 'properties' => $properties, - 'last_error' => $isReachable ? null : 'Database service is not active on the node.', - ]; - - if ($version !== '' && ! $databaseServer->installed_at) { - $payload['installed_at'] = now(); - } - - $databaseServer->forceFill($payload)->save(); - } catch (Throwable $exception) { - $this->fail($databaseServer, $exception->getMessage()); - } - } - - private function fail(DatabaseServer $databaseServer, string $message): void - { - $databaseServer->forceFill([ - 'status' => 'error', - 'last_error' => $message, - ])->save(); - } - - private function binaryProbeCommand(string $driver): string - { - return match ($driver) { - 'pgsql' => 'psql --version || true', - 'mariadb' => 'mariadb --version || mysql --version || true', - default => 'mysql --version || true', - }; - } - - private function serviceProbeCommand(string $driver): string - { - return match ($driver) { - 'pgsql' => 'systemctl is-active postgresql || true', - 'mariadb' => 'systemctl is-active mariadb || true', - default => 'systemctl is-active mysql || systemctl is-active mysqld || true', - }; - } -} diff --git a/packages/developer/.gitignore b/packages/developer/.gitignore deleted file mode 100644 index 8a33e12..0000000 --- a/packages/developer/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules -/vendor -composer.lock -package-lock.json diff --git a/packages/developer/composer.json b/packages/developer/composer.json deleted file mode 100644 index f95942a..0000000 --- a/packages/developer/composer.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://getcomposer.org/schema.json", - "name": "froxlor/developer", - "type": "library", - "description": "The froxlor developer package.", - "keywords": [ - "froxlor", - "developer", - "documentation" - ], - "homepage": "https://www.froxlor.org", - "license": "proprietary", - "authors": [ - { - "name": "Michael Kaufmann", - "email": "d00p@froxlor.org", - "role": "Lead Developer" - }, - { - "name": "Maurice Preuß", - "email": "envoyr@froxlor.org", - "role": "Developer" - } - ], - "require": { - "php": "^8.5", - "froxlor/core": "*", - "froxlor/ui": "*", - "illuminate/support": "^12.0" - }, - "autoload": { - "psr-4": { - "Froxlor\\Developer\\": "src/" - } - }, - "extra": { - "froxlor": { - "type": "package" - }, - "laravel": { - "providers": [ - "Froxlor\\Developer\\Providers\\FroxlorDeveloperServiceProvider" - ] - } - } -} diff --git a/packages/developer/resources/views/components/base-layout.blade.php b/packages/developer/resources/views/components/base-layout.blade.php deleted file mode 100644 index 77c8e42..0000000 --- a/packages/developer/resources/views/components/base-layout.blade.php +++ /dev/null @@ -1,13 +0,0 @@ -@props(['title' => 'froxlor Development Kit']) - - - - - - - - {{ $slot }} - - - - diff --git a/packages/developer/resources/views/docs/components/accordion.blade.php b/packages/developer/resources/views/docs/components/accordion.blade.php deleted file mode 100644 index badd399..0000000 --- a/packages/developer/resources/views/docs/components/accordion.blade.php +++ /dev/null @@ -1,75 +0,0 @@ - - -
- Components - Accordion -
-
- - - - - - - - - - Section 1 - Title - - - Hello World! - - - - - Section 2 - Title - - - Hello World! - - - - - Section 3 - Title - - - Hello World! - - - - - - - - @verbatim - - - - Section 1 - Title - - - Hello World! - - - - - Section 2 - Title - - - Hello World! - - - - - Section 3 - Title - - - Hello World! - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/alert.blade.php b/packages/developer/resources/views/docs/components/alert.blade.php deleted file mode 100644 index f05094e..0000000 --- a/packages/developer/resources/views/docs/components/alert.blade.php +++ /dev/null @@ -1,127 +0,0 @@ - - -
- Components - Alert -
-
- - - - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - - @verbatim - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - - - Heads up! - This is a test. - - @endverbatim - - - - - Variants - - - - Heads up! - - - - Heads up! - - - - - Heads up! - - - - - Heads up! - This is a test. - - - - - @verbatim - - Heads up! - - - - Heads up! - - - - - Heads up! - - - - - Heads up! - This is a test. - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/avatar.blade.php b/packages/developer/resources/views/docs/components/avatar.blade.php deleted file mode 100644 index 9f8989e..0000000 --- a/packages/developer/resources/views/docs/components/avatar.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - -
- Components - Avatar -
-
- - - - - - - - - - - - - - - - - - @verbatim - - - - - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/badge.blade.php b/packages/developer/resources/views/docs/components/badge.blade.php deleted file mode 100644 index 4d0f3d2..0000000 --- a/packages/developer/resources/views/docs/components/badge.blade.php +++ /dev/null @@ -1,56 +0,0 @@ - - -
- Components - Badge -
-
- - - - - - Badge - - - - @verbatim - Badge - @endverbatim - - - - - - - Variants - - -
- Badge - Badge - Badge - Badge - Badge - Badge - Badge - Badge -
-
- - - @verbatim - Badge - Badge - Badge - Badge - Badge - Badge - Badge - Badge - Badge - @endverbatim - -
-
-
diff --git a/packages/developer/resources/views/docs/components/body.blade.php b/packages/developer/resources/views/docs/components/body.blade.php deleted file mode 100644 index a0ce12e..0000000 --- a/packages/developer/resources/views/docs/components/body.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - - -
- Components - Body -
-
- - - - - - - - - - - - @verbatim - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/breadcrumb.blade.php b/packages/developer/resources/views/docs/components/breadcrumb.blade.php deleted file mode 100644 index 2317678..0000000 --- a/packages/developer/resources/views/docs/components/breadcrumb.blade.php +++ /dev/null @@ -1,57 +0,0 @@ - - -
- Components - Breadcrumb -
-
- - - - - -
- - - Home - - - - - - - - Tenants - - - - Environments - - -
-
- - - @verbatim - - - Home - - - - - - - - Tenants - - - - Environments - - - @endverbatim - -
-
-
diff --git a/packages/developer/resources/views/docs/components/button-group.blade.php b/packages/developer/resources/views/docs/components/button-group.blade.php deleted file mode 100644 index 23110b3..0000000 --- a/packages/developer/resources/views/docs/components/button-group.blade.php +++ /dev/null @@ -1,49 +0,0 @@ - - -
- Components - Button Group -
-
- - - - - -
- - 123 - - - - Button - Button - - - - Button - Button - -
-
- - - @verbatim - - Button - - - - Button - Button - - - - Button - Button - - @endverbatim - -
-
-
diff --git a/packages/developer/resources/views/docs/components/button.blade.php b/packages/developer/resources/views/docs/components/button.blade.php deleted file mode 100644 index 225ebfd..0000000 --- a/packages/developer/resources/views/docs/components/button.blade.php +++ /dev/null @@ -1,58 +0,0 @@ - - -
- Components - Button -
-
- - - - - - Button - - - - @verbatim - Button - @endverbatim - - - - - - - Variants - - -
- Button - Button - Button - Button - Button - Button - Button - Button - Button -
-
- - - @verbatim - Button - Button - Button - Button - Button - Button - Button - Button - Button - Button - @endverbatim - -
-
-
diff --git a/packages/developer/resources/views/docs/components/card.blade.php b/packages/developer/resources/views/docs/components/card.blade.php deleted file mode 100644 index 87c189a..0000000 --- a/packages/developer/resources/views/docs/components/card.blade.php +++ /dev/null @@ -1,104 +0,0 @@ - - -
- Components - Card -
-
- - - - - - - - Title - Description - - - Content - - - - - - @verbatim - - - Title - Description - - - Content - - - @endverbatim - - - - Header - - - - - Title - Description - - - - - - @verbatim - - - Title - Description - - - @endverbatim - - - - Content - - - - - Content - - - - - - @verbatim - - - Content - - - @endverbatim - - - - Footer - - - - - Footer - - - - - - @verbatim - - - Footer - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/chart.blade.php b/packages/developer/resources/views/docs/components/chart.blade.php deleted file mode 100644 index 2275153..0000000 --- a/packages/developer/resources/views/docs/components/chart.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Chart -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/checkbox.blade.php b/packages/developer/resources/views/docs/components/checkbox.blade.php deleted file mode 100644 index 1ea9f37..0000000 --- a/packages/developer/resources/views/docs/components/checkbox.blade.php +++ /dev/null @@ -1,65 +0,0 @@ - - -
- Components - Checkbox -
-
- - - - - Good to know - - Use <x-ui::input.checkbox /> for both labeled form fields and compact selection controls such as table bulk actions. - - - - - - - - - - - - @verbatim - - @endverbatim - - - - - - - Ich akzeptiere die AGB - - - - - @verbatim - - Ich akzeptiere die AGB - - @endverbatim - - - - - -
-
- Compact selection checkbox - -
-
-
- - - @verbatim - - @endverbatim - -
-
-
diff --git a/packages/developer/resources/views/docs/components/code.blade.php b/packages/developer/resources/views/docs/components/code.blade.php deleted file mode 100644 index fafff31..0000000 --- a/packages/developer/resources/views/docs/components/code.blade.php +++ /dev/null @@ -1,46 +0,0 @@ - - -
- Components - Code -
-
- - - - Code - - - - <!-- ... --> - - - - - @verbatim - - - - @endverbatim - - - - - - - Pre - - - <!-- ... --> - - - - @verbatim - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/collapsible.blade.php b/packages/developer/resources/views/docs/components/collapsible.blade.php deleted file mode 100644 index 02f58b9..0000000 --- a/packages/developer/resources/views/docs/components/collapsible.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - -
- Components - Collapsible -
-
- - - - - - - - - Toggle Content - - - Hello World! - - - - - - - @verbatim - - - Toggle Content - - - Hello World! - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/date-picker.blade.php b/packages/developer/resources/views/docs/components/date-picker.blade.php deleted file mode 100644 index 0ab2b26..0000000 --- a/packages/developer/resources/views/docs/components/date-picker.blade.php +++ /dev/null @@ -1,83 +0,0 @@ - - -
- Components - Date Picker -
-
- - - - - -
- -
-
- - - @verbatim - - @endverbatim - -
-
- - - - Custom formats - - -
- - -
-
- - - @verbatim - - - @endverbatim - -
-
- - - - Min/Max and week start - - -
- - -
-
- - - @verbatim - - - @endverbatim - -
-
- - - - Disabled - - -
- -
-
- - - @verbatim - - @endverbatim - -
-
-
diff --git a/packages/developer/resources/views/docs/components/description-list.blade.php b/packages/developer/resources/views/docs/components/description-list.blade.php deleted file mode 100644 index 4c0d803..0000000 --- a/packages/developer/resources/views/docs/components/description-list.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Description List -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/dialog.blade.php b/packages/developer/resources/views/docs/components/dialog.blade.php deleted file mode 100644 index 4483a62..0000000 --- a/packages/developer/resources/views/docs/components/dialog.blade.php +++ /dev/null @@ -1,73 +0,0 @@ - - -
- Components - Dialog -
-
- - - - - -
- - Open - - - - - - Title - Description - - - - Consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis - nostrud exercitation ullamco laboris nisi ut aliquip ex ea - commodo consequat. - - - - - Close - - - - -
-
- - - @verbatim - - Open - - - - - - Title - Description - - - - Consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis - nostrud exercitation ullamco laboris nisi ut aliquip ex ea - commodo consequat. - - - - - Close - - - - - @endverbatim - -
-
-
diff --git a/packages/developer/resources/views/docs/components/dropdown.blade.php b/packages/developer/resources/views/docs/components/dropdown.blade.php deleted file mode 100644 index 0f1830e..0000000 --- a/packages/developer/resources/views/docs/components/dropdown.blade.php +++ /dev/null @@ -1,132 +0,0 @@ - - -
- Components - Dropdown -
-
- - - - - - - - Open menu - - - - Profile - Settings - - Sign out - - - - - - @verbatim - - - - Open menu - - - - Profile - Settings - - Sign out - - - @endverbatim - - - - - - - Alignment - - -
- - - Left - - - Item A - Item B - - - - - - Right (default) - - - Item A - Item B - - - - - - Top - - - Item A - Item B - - -
-
- - - @verbatim - - - - @endverbatim - -
-
- - - - Custom content styling - - - - - Custom content - - -
- First - - Second -
-
-
-
- - - @verbatim - - - Custom content - - -
- First - - Second -
-
-
- @endverbatim -
-
-
-
diff --git a/packages/developer/resources/views/docs/components/field.blade.php b/packages/developer/resources/views/docs/components/field.blade.php deleted file mode 100644 index c56529a..0000000 --- a/packages/developer/resources/views/docs/components/field.blade.php +++ /dev/null @@ -1,42 +0,0 @@ - - -
- Components - Field -
-
- - - - - Good to know - - Check out the <ui::form> component as well, which combines the field with a label and optional help text and error message. - - - - - - - - - - - - - - - - - @verbatim - - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/flex.blade.php b/packages/developer/resources/views/docs/components/flex.blade.php deleted file mode 100644 index 2bf0f30..0000000 --- a/packages/developer/resources/views/docs/components/flex.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: Docs,info --}} - - -
- Components - Flex -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/form.blade.php b/packages/developer/resources/views/docs/components/form.blade.php deleted file mode 100644 index 3209aba..0000000 --- a/packages/developer/resources/views/docs/components/form.blade.php +++ /dev/null @@ -1,86 +0,0 @@ -{{-- Status: Essentials,info --}} - - -
- Components - From -
-
- - - - - Good to know - - This page describes the <ui::form> component, which combines various input types (like text inputs, selects, textareas, etc.) with a label, optional help text, and error messages into a single, reusable component. - - - - - - - - The code may change during development. - - - - - - - - - - - - - - - - - - - - - - - - - - Save - - - - - - @verbatim - - - - - - - - - - - - - - - - - - - - - - - - Save - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/grid.blade.php b/packages/developer/resources/views/docs/components/grid.blade.php deleted file mode 100644 index a76d29b..0000000 --- a/packages/developer/resources/views/docs/components/grid.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: Docs,info --}} - - -
- Components - Grid -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/heading.blade.php b/packages/developer/resources/views/docs/components/heading.blade.php deleted file mode 100644 index 6345fa9..0000000 --- a/packages/developer/resources/views/docs/components/heading.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - -
- Components - Heading -
-
- - - - - - -
- Hello World - Hello World -
- - Action - -
-
- - - @verbatim - -
- Hello World - Hello World -
- - Action - -
- @endverbatim -
-
-
-
diff --git a/packages/developer/resources/views/docs/components/icon.blade.php b/packages/developer/resources/views/docs/components/icon.blade.php deleted file mode 100644 index e1a2ecd..0000000 --- a/packages/developer/resources/views/docs/components/icon.blade.php +++ /dev/null @@ -1,91 +0,0 @@ - - -
- Components - Icon -
-
- - - - The icon component utilizes the Lucide icon library. - - - - - - - - - - - - - - - - - @verbatim - - - - - - - - - @endverbatim - - - - - - Variants - - - - - - - - - - - - - - - @verbatim - - - - - - - @endverbatim - - - - - - Sizes - - - - - - - - - - - - @verbatim - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/input-group.blade.php b/packages/developer/resources/views/docs/components/input-group.blade.php deleted file mode 100644 index 1dbbb7b..0000000 --- a/packages/developer/resources/views/docs/components/input-group.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Input Group -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/input.blade.php b/packages/developer/resources/views/docs/components/input.blade.php deleted file mode 100644 index 9f52c2f..0000000 --- a/packages/developer/resources/views/docs/components/input.blade.php +++ /dev/null @@ -1,32 +0,0 @@ - - -
- Components - Input -
-
- - - - - Good to know - - Check out the <ui::form> component as well, which combines the input with a label and optional help text and error message. - - - - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/item.blade.php b/packages/developer/resources/views/docs/components/item.blade.php deleted file mode 100644 index 36b8678..0000000 --- a/packages/developer/resources/views/docs/components/item.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Item -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/kbd.blade.php b/packages/developer/resources/views/docs/components/kbd.blade.php deleted file mode 100644 index df7792f..0000000 --- a/packages/developer/resources/views/docs/components/kbd.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Kbd -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/label.blade.php b/packages/developer/resources/views/docs/components/label.blade.php deleted file mode 100644 index 4feace7..0000000 --- a/packages/developer/resources/views/docs/components/label.blade.php +++ /dev/null @@ -1,32 +0,0 @@ - - -
- Components - Label -
-
- - - - - Good to know - - Check out the <ui::form> component as well, which combines the label with a input and optional help text and error message. - - - - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/lead.blade.php b/packages/developer/resources/views/docs/components/lead.blade.php deleted file mode 100644 index d5d2e60..0000000 --- a/packages/developer/resources/views/docs/components/lead.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - -
- Components - Lead -
-
- - - - - - Well, let me tell you something, ... - - - - @verbatim - Well, let me tell you something, ... - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/link.blade.php b/packages/developer/resources/views/docs/components/link.blade.php deleted file mode 100644 index 59d945e..0000000 --- a/packages/developer/resources/views/docs/components/link.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - -
- Components - Link -
-
- - - - - - froxlor.org - - - - @verbatim - froxlor.org - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/logo.blade.php b/packages/developer/resources/views/docs/components/logo.blade.php deleted file mode 100644 index 3e9d4de..0000000 --- a/packages/developer/resources/views/docs/components/logo.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - -
- Components - Logo -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/main.blade.php b/packages/developer/resources/views/docs/components/main.blade.php deleted file mode 100644 index 3d77968..0000000 --- a/packages/developer/resources/views/docs/components/main.blade.php +++ /dev/null @@ -1,29 +0,0 @@ - - -
- Components - Main -
-
- - - - - - - - - - - - - - @verbatim - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/middle.blade.php b/packages/developer/resources/views/docs/components/middle.blade.php deleted file mode 100644 index 1f721f8..0000000 --- a/packages/developer/resources/views/docs/components/middle.blade.php +++ /dev/null @@ -1,29 +0,0 @@ - - -
- Components - Middle -
-
- - - - - - - - - - - - - - @verbatim - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/navbar.blade.php b/packages/developer/resources/views/docs/components/navbar.blade.php deleted file mode 100644 index 89f91ac..0000000 --- a/packages/developer/resources/views/docs/components/navbar.blade.php +++ /dev/null @@ -1,82 +0,0 @@ - - -
- Components - Navbar -
-
- - - - - Attention! - - You might want to use the <livewire:ui::navbar navigation="primary" user-navigation="user"/> component for most use-cases, check the docs for more info. The <x-ui::navbar> component is a low-level building block for custom navigation bars. - - - - - - - - - - - - Title - - - - - - - - - One - - - Two - - - Three - - - - - - - - - - @verbatim - - - - - Title - - - - - - - - - One - - - Two - - - Three - - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/navigation-menu.blade.php b/packages/developer/resources/views/docs/components/navigation-menu.blade.php deleted file mode 100644 index 210e277..0000000 --- a/packages/developer/resources/views/docs/components/navigation-menu.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -{{-- Status: Refactor/Docs,warning --}} - - -
- Components - Navigation Menu -
-
- - -
diff --git a/packages/developer/resources/views/docs/components/number-field.blade.php b/packages/developer/resources/views/docs/components/number-field.blade.php deleted file mode 100644 index c17e468..0000000 --- a/packages/developer/resources/views/docs/components/number-field.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Number Field -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/pagination.blade.php b/packages/developer/resources/views/docs/components/pagination.blade.php deleted file mode 100644 index 8ab5ac5..0000000 --- a/packages/developer/resources/views/docs/components/pagination.blade.php +++ /dev/null @@ -1,44 +0,0 @@ -{{-- Status: Experimental,danger --}} - - -
- Components - Pagination -
-
- - - - - Good to know - - The <x-ui::pagination> component supports two modes:
- 1) Pass a Laravel paginator via :paginator
- 2) Pass manual props :current and :total.
- Optional: surround controls how many pages appear around the current, and pageName customizes the query key (default: page). -
-
- - - - - - - - - - - @verbatim - - - - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/placeholder.blade.php b/packages/developer/resources/views/docs/components/placeholder.blade.php deleted file mode 100644 index 2410ae8..0000000 --- a/packages/developer/resources/views/docs/components/placeholder.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - -
- Components - Placeholder -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/popover.blade.php b/packages/developer/resources/views/docs/components/popover.blade.php deleted file mode 100644 index 42ca86b..0000000 --- a/packages/developer/resources/views/docs/components/popover.blade.php +++ /dev/null @@ -1,65 +0,0 @@ - - -
- Components - Popover -
-
- - - - - -
- - - Open - - -
-
Popover Title
-
Any content in the popover.
- Action -
-
-
- - - - Right - - -
Popover on the right, start aligned.
-
-
-
-
- - - @verbatim - - - Open - - -
-
Popover Title
-
Any content in the popover.
- Action -
-
-
- - - - Right - - -
Popover on the right, start aligned.
-
-
- @endverbatim -
-
-
-
diff --git a/packages/developer/resources/views/docs/components/progress-group.blade.php b/packages/developer/resources/views/docs/components/progress-group.blade.php deleted file mode 100644 index 5fc03f9..0000000 --- a/packages/developer/resources/views/docs/components/progress-group.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - - -
- Components - Progress Group -
-
- - - - - - - - - - - - - - @verbatim - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/progress.blade.php b/packages/developer/resources/views/docs/components/progress.blade.php deleted file mode 100644 index 93a34e5..0000000 --- a/packages/developer/resources/views/docs/components/progress.blade.php +++ /dev/null @@ -1,43 +0,0 @@ - - -
- Components - Progress -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - - - - Variants - - - - - - - - - - @verbatim - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/radio-group.blade.php b/packages/developer/resources/views/docs/components/radio-group.blade.php deleted file mode 100644 index ca0f3ec..0000000 --- a/packages/developer/resources/views/docs/components/radio-group.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Radio Group -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/section.blade.php b/packages/developer/resources/views/docs/components/section.blade.php deleted file mode 100644 index 4f543f3..0000000 --- a/packages/developer/resources/views/docs/components/section.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Components - Section -
-
- - - - - - Hello World - - - - @verbatim - Hello World - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/select.blade.php b/packages/developer/resources/views/docs/components/select.blade.php deleted file mode 100644 index 4ad9317..0000000 --- a/packages/developer/resources/views/docs/components/select.blade.php +++ /dev/null @@ -1,32 +0,0 @@ - - -
- Components - Select -
-
- - - - - Good to know - - Check out the <ui::form> component as well, which combines the select with a label and optional help text and error message. - - - - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/separator.blade.php b/packages/developer/resources/views/docs/components/separator.blade.php deleted file mode 100644 index 69e88f8..0000000 --- a/packages/developer/resources/views/docs/components/separator.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Separator -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/sidebar.blade.php b/packages/developer/resources/views/docs/components/sidebar.blade.php deleted file mode 100644 index 7e31f4e..0000000 --- a/packages/developer/resources/views/docs/components/sidebar.blade.php +++ /dev/null @@ -1,112 +0,0 @@ - - -
- Components - Sidebar -
-
- - - - - Attention! - - You might want to use the <livewire:ui::navigations.sidebar navigation="primary"/> component for most use-cases, check the docs for more info. The <x-ui::sidebar> component is a low-level building block for custom navigation sidebars. - - - - - - - - -
- - - - - - - - - - Application - - - Home - Node - - Other - - First - Second - - - - - - - - - - Footer - - -
-
-
- - - @verbatim - - - - - - - - - - Application - - - Home - Node - - Other - - First - Second - - - - - - - - - - Footer - - - @endverbatim - -
-
- - - Trigger - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/skeleton.blade.php b/packages/developer/resources/views/docs/components/skeleton.blade.php deleted file mode 100644 index 68545dd..0000000 --- a/packages/developer/resources/views/docs/components/skeleton.blade.php +++ /dev/null @@ -1,115 +0,0 @@ - - -
- Components - Skeleton -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - - - - - Variants - - -
-
- Rectangle sizes -
- - - -
-
- -
- Text lines - -
- -
- Circle (avatar) -
- - - -
-
-
-
- - - @verbatim - - - - - - - - - - - - - @endverbatim - -
-
- - - - Composite examples - - -
- -
- -
- -
-
- - -
- - -
-
-
- - - @verbatim - -
- -
- -
-
- - -
- - -
- @endverbatim -
-
-
-
diff --git a/packages/developer/resources/views/docs/components/space.blade.php b/packages/developer/resources/views/docs/components/space.blade.php deleted file mode 100644 index b952fd0..0000000 --- a/packages/developer/resources/views/docs/components/space.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Legacy: Docs,warning --}} - - -
- Components - Space -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/spinner.blade.php b/packages/developer/resources/views/docs/components/spinner.blade.php deleted file mode 100644 index f7d4e0f..0000000 --- a/packages/developer/resources/views/docs/components/spinner.blade.php +++ /dev/null @@ -1,33 +0,0 @@ - - -
- Components - Spinner -
-
- - - - - Good to know - - The spinner component is built using the <ui::icon ...> component with the loader-circle as icon and applies a spinning animation. - So you can customize it using the same properties as the icon component. - - - - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/stepper.blade.php b/packages/developer/resources/views/docs/components/stepper.blade.php deleted file mode 100644 index 4907134..0000000 --- a/packages/developer/resources/views/docs/components/stepper.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Stepper -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/subtitle.blade.php b/packages/developer/resources/views/docs/components/subtitle.blade.php deleted file mode 100644 index 13c6394..0000000 --- a/packages/developer/resources/views/docs/components/subtitle.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - -
- Components - Subtitle -
-
- - - - - - Hello World - - - - @verbatim - Hello World - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/table.blade.php b/packages/developer/resources/views/docs/components/table.blade.php deleted file mode 100644 index f899598..0000000 --- a/packages/developer/resources/views/docs/components/table.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- Status: ToDo,outline --}} - - -
- Components - Table -
-
- - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/tabs.blade.php b/packages/developer/resources/views/docs/components/tabs.blade.php deleted file mode 100644 index 0fde34f..0000000 --- a/packages/developer/resources/views/docs/components/tabs.blade.php +++ /dev/null @@ -1,69 +0,0 @@ - - -
- Components - Tabs -
-
- - - - - - - - Overview - Domains - Databases - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed - euismod, nunc ut laoreet aliquam, nunc nisl aliquet nunc, eu - aliquam nisl nunc euismod nunc. - - - Duis aute irure dolor in reprehenderit in voluptate velit esse - cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat - cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum. - - - Et harum quidem rerum facilis est et expedita distinctio. Nam - libero tempore, cum soluta nobis est eligendi optio cumque nihil - impedit quo minus id quod maxime placeat facere possimus, omnis - voluptas assumenda est, omnis dolor repellendus. - - - - - - @verbatim - - - Overview - Domains - Databases - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed - euismod, nunc ut laoreet aliquam, nunc nisl aliquet nunc, eu - aliquam nisl nunc euismod nunc. - - - Duis aute irure dolor in reprehenderit in voluptate velit esse - cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat - cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum. - - - Et harum quidem rerum facilis est et expedita distinctio. Nam - libero tempore, cum soluta nobis est eligendi optio cumque nihil - impedit quo minus id quod maxime placeat facere possimus, omnis - voluptas assumenda est, omnis dolor repellendus. - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/teaser.blade.php b/packages/developer/resources/views/docs/components/teaser.blade.php deleted file mode 100644 index ecbb816..0000000 --- a/packages/developer/resources/views/docs/components/teaser.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - -
- Components - Teaser -
-
- - - - - - Hello World - - - - @verbatim - Hello World - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/text.blade.php b/packages/developer/resources/views/docs/components/text.blade.php deleted file mode 100644 index c9164df..0000000 --- a/packages/developer/resources/views/docs/components/text.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - -
- Components - Text -
-
- - - - - - Well, let me tell you something, ... - - - - @verbatim - Well, let me tell you something, ... - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/textarea.blade.php b/packages/developer/resources/views/docs/components/textarea.blade.php deleted file mode 100644 index d7de682..0000000 --- a/packages/developer/resources/views/docs/components/textarea.blade.php +++ /dev/null @@ -1,32 +0,0 @@ - - -
- Components - Textarea -
-
- - - - - Good to know - - Check out the <ui::form> component as well, which combines the textarea with a label and optional help text and error message. - - - - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/title.blade.php b/packages/developer/resources/views/docs/components/title.blade.php deleted file mode 100644 index 4d3d70e..0000000 --- a/packages/developer/resources/views/docs/components/title.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - - -
- Components - Title -
-
- - - - - - Hello World - Hello World - Hello World - - - - @verbatim - Hello World - Hello World - Hello World - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/toast.blade.php b/packages/developer/resources/views/docs/components/toast.blade.php deleted file mode 100644 index bf1c5a2..0000000 --- a/packages/developer/resources/views/docs/components/toast.blade.php +++ /dev/null @@ -1,40 +0,0 @@ -{{-- Status: Experimental,danger --}} - - -
- Components - Toast -
-
- - - - - -
- - - - Show toast - -
-
- - - @verbatim - - - - Show toast - - - - - - - - @endverbatim - -
-
-
diff --git a/packages/developer/resources/views/docs/components/toggle.blade.php b/packages/developer/resources/views/docs/components/toggle.blade.php deleted file mode 100644 index 770343f..0000000 --- a/packages/developer/resources/views/docs/components/toggle.blade.php +++ /dev/null @@ -1,48 +0,0 @@ - - -
- Components - Toggle -
-
- - - - - Good to know - - Check out the <ui::form> component as well, which combines the toggle with a label and optional help text and error message. - - - - - - - - - - - - @verbatim - - @endverbatim - - - - - - - I accept the Terms and Conditions - - - - - @verbatim - - I accept the Terms and Conditions - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/components/tooltip.blade.php b/packages/developer/resources/views/docs/components/tooltip.blade.php deleted file mode 100644 index 8665f60..0000000 --- a/packages/developer/resources/views/docs/components/tooltip.blade.php +++ /dev/null @@ -1,47 +0,0 @@ - - -
- Components - Tooltip -
-
- - - - - -
- - Hover me - - - - -
Rich Tooltip
-
With any content, links, and more.
-
- - Rich content -
-
-
- - - @verbatim - - - Hover me - - - - - -
Rich Tooltip
-
With any content, links, and more.
-
-
- @endverbatim -
-
-
-
diff --git a/packages/developer/resources/views/docs/forms/components.blade.php b/packages/developer/resources/views/docs/forms/components.blade.php deleted file mode 100644 index 2612066..0000000 --- a/packages/developer/resources/views/docs/forms/components.blade.php +++ /dev/null @@ -1,151 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Forms - Components -
-
- - - - Experimental feature ahead! - The form builder is currently in development and not final, methods may change over time. - - - - All form components live in the Froxlor\UI\Forms\Components namespace. The examples below showcase typical configurations together with validation helpers. - - - - Text Input - Flexible single-line input with helpers for common HTML5 types. - - - @verbatim - Forms\Components\TextInput::make('email') - ->label(trans('froxlor-core::generic.email')) - ->required() - ->email() - ->rules(['email', 'max:255']); - - Forms\Components\TextInput::make('password') - ->label(trans('froxlor-core::generic.password')) - ->required() - ->password(); - - Forms\Components\TextInput::make('url') - ->label('Repository URL') - ->url() - ->default(fn() => config('app.url')); - @endverbatim - - - - - - Text Area - Multi-line text entry for longer descriptions or notes. Use ->fill(['rows' => 5]) if you need a custom height. - - - @verbatim - Forms\Components\TextArea::make('description') - ->label(trans('froxlor-core::generic.description')) - ->default('') - ->rules(['nullable', 'max:2000']) - ->fill(['rows' => 5, 'placeholder' => trans('froxlor-core::generic.description_placeholder')]); - @endverbatim - - - - - - Select - Dropdown selector that can hydrate options lazily or from arrays. - - - @verbatim - use Froxlor\Core\Models\Node; - use Froxlor\Core\Services\Node\Adapter\Local; - - Forms\Components\Select::make('adapter') - ->label(trans('froxlor-core::generic.adapter')) - ->options(fn() => array_map(fn ($adapter) => trans($adapter::$name), Node::adapters())) - ->default(Local::class) - ->required(); - - Forms\Components\Select::make('theme') - ->label(trans('froxlor-ui::generic.theme')) - ->options([ - 'light' => trans('froxlor-ui::generic.light'), - 'dark' => trans('froxlor-ui::generic.dark'), - 'system' => trans('froxlor-ui::generic.system_default'), - ]) - ->default('system'); - @endverbatim - - - - - - Boolean - Toggle for true/false flags rendered in the standard froxlor switch style. - - - @verbatim - Forms\Components\Boolean::make('sudo') - ->label(trans('froxlor-core::generic.sudo')) - ->default(false); - @endverbatim - - - - - - Color Picker - Capture brand or accent colors with validation for proper hex values. - - - @verbatim - Forms\Components\Color::make('brand_color') - ->label('Brand color') - ->default('#0099ff') - ->rules(['required', 'regex:/^#([A-Fa-f0-9]{6})$/']); - @endverbatim - - - - - - Placeholder - Read-only output for system-generated data, often paired with timestamps. - - - @verbatim - use Froxlor\Core\Models\User; - - Forms\Components\Placeholder::make('created_at') - ->label(trans('froxlor-core::generic.created_at')) - ->default(fn (User $user) => $user->created_at?->diffForHumans()); - @endverbatim - - - - - - Dump - Developer-centric helper for rendering arbitrary payloads while iterating on a schema. - - - @verbatim - Forms\Components\Dump::make('debug_payload') - ->label('Debug payload') - ->default(fn () => [ - 'plan' => 'premium', - 'features' => ['mail', 'dns', 'backups'], - ]); - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/forms/overview.blade.php b/packages/developer/resources/views/docs/forms/overview.blade.php deleted file mode 100644 index 3a0e7fd..0000000 --- a/packages/developer/resources/views/docs/forms/overview.blade.php +++ /dev/null @@ -1,75 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Forms - Overview -
-
- - - - Experimental feature ahead! - The form builder is currently in development and not final, methods may change over time. - - - - What a Form Schema Does - Form schemas power create and edit flows for resources like UserResource and NodeResource. They bundle HTTP intentions, layout primitives, validation, and UI components into a single definition that the Vue front end renders automatically. - - - - Key Building Blocks - - - - Lifecycle & Routing - Describe how the form communicates with the API. - - - Use push() to configure the submission endpoint and HTTP verb. fetch() hydrates existing records (for edit forms) and intendedRoute() defines where to redirect on success. - - - - - - Layout & Grouping - Keep complex forms manageable by grouping fields. - - - Structure inputs with Schemas\Components\Section and Group. Combine them with ->colSpan() or ->cols() to control responsive layouts, just like UserResource::edit. - - - - - - Inputs & Validation - Reuse opinionated UI components while enforcing business rules. - - - The Forms\Components namespace contains expressive builders such as Select, TextInput, Boolean, or Color. Chain helpers like ->required(), ->rules(), or ->default() to match your validation layer. - - - - - - Footer Actions - Add navigation or secondary actions directly to the form footer. - - - Attach buttons via Forms\Actions\Action. Typical examples include a “Back” link to the index table or a destructive action such as delete. - - - - - - - Implementation Tips - - - - Extract reusable sections (for example credentials or connection details) into dedicated methods when you build multiple resources, such as UserResource and NodeResource. This keeps validation rules aligned and reduces translation drift. - - - -
diff --git a/packages/developer/resources/views/docs/forms/quick-start.blade.php b/packages/developer/resources/views/docs/forms/quick-start.blade.php deleted file mode 100644 index eb3297a..0000000 --- a/packages/developer/resources/views/docs/forms/quick-start.blade.php +++ /dev/null @@ -1,129 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Forms - Quick Start -
-
- - - - Experimental feature ahead! - The form builder is currently in development and not final, methods may change over time. - - - - Create a Resource Form - This example mirrors the create flow used in UserResource. It wires the HTTP endpoint, groups fields, and defines footer actions. - - - - @verbatim - use Froxlor\UI\Forms; - use Froxlor\UI\Forms\Form; - use Froxlor\UI\Schemas\Components\Section; - - return Form::make() - ->title(trans('froxlor-core::generic.create_resource')) - ->description(trans('froxlor-core::generic.create_resource')) - ->push(route('api.users.store')) - ->intendedRoute('users.index') - ->schema([ - Section::make('profile') - ->title('Profile') - ->schema([ - Forms\Components\TextInput::make('first_name') - ->label(trans('froxlor-core::generic.first_name')) - ->required() - ->col(3), - - Forms\Components\TextInput::make('last_name') - ->label(trans('froxlor-core::generic.last_name')) - ->required() - ->col(3), - - Forms\Components\TextInput::make('company_name') - ->label(trans('froxlor-core::generic.company_name')), - ]), - - Section::make('credentials') - ->title('Credentials') - ->schema([ - Forms\Components\TextInput::make('email') - ->label(trans('froxlor-core::generic.email')) - ->required() - ->email(), - - Forms\Components\TextInput::make('password') - ->label(trans('froxlor-core::generic.password')) - ->required() - ->password(), - ]), - ]) - ->actions([ - Forms\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('auth.users.index')), - ]); - @endverbatim - - - - - - Editing with the Same Schema - Re-use the create schema to keep the UI consistent when editing. Add fetch() and adjust push() to update records in place. - - - - @verbatim - public function edit(User $user): Form - { - return $this->create() - ->fetch(route('api.users.show', $user)) - ->push(route('api.users.update', $user), 'PUT') - ->cols(3); - } - @endverbatim - - - - - - Grouping with Sections and Groups - UserResource::edit nests sections inside a group to create a two-column layout for primary and auxiliary information. - - - - @verbatim - use Froxlor\UI\Schemas\Components\Group; - - Group::make('account_overview') - ->schema([ - Section::make('main_details') - ->title(trans('froxlor-core::generic.title')) - ->description('The account profile and credentials.') - ->schema([ - Forms\Components\TextInput::make('first_name') - ->label(trans('froxlor-core::generic.first_name')) - ->required() - ->col(3), - - Forms\Components\TextInput::make('last_name') - ->label(trans('froxlor-core::generic.last_name')) - ->required() - ->col(3), - - Forms\Components\TextInput::make('email') - ->label(trans('froxlor-core::generic.email')) - ->required() - ->email(), - ]), - ]) - ->colSpan(2); - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/layouts/app-layout.blade.php b/packages/developer/resources/views/docs/layouts/app-layout.blade.php deleted file mode 100644 index b232124..0000000 --- a/packages/developer/resources/views/docs/layouts/app-layout.blade.php +++ /dev/null @@ -1,37 +0,0 @@ - - -
- Layouts - App Layout -
-
- - - - - - Info - By default you can use the ui::auth-layout layout to use the full layout with navbar and sidebar. If you want to use the layout without navbar and sidebar, you can use the ui::app-layout layout and place your main content inside the slot section. - - - - - - - - - - - - - @verbatim - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/layouts/auth-layout.blade.php b/packages/developer/resources/views/docs/layouts/auth-layout.blade.php deleted file mode 100644 index a8c5a8b..0000000 --- a/packages/developer/resources/views/docs/layouts/auth-layout.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - - -
- Layouts - Auth Layout -
-
- - - - - - - - - - - - - - @verbatim - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/layouts/guest-layout.blade.php b/packages/developer/resources/views/docs/layouts/guest-layout.blade.php deleted file mode 100644 index 531aeee..0000000 --- a/packages/developer/resources/views/docs/layouts/guest-layout.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - - -
- Layouts - Guest Layout -
-
- - - - - - - - - - - - @verbatim - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/layouts/sidebar-layout.blade.php b/packages/developer/resources/views/docs/layouts/sidebar-layout.blade.php deleted file mode 100644 index 8930406..0000000 --- a/packages/developer/resources/views/docs/layouts/sidebar-layout.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - - -
- Layouts - Sidebar Layout -
-
- - - - - - - - - - - - - - @verbatim - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/layouts/stacked-layout.blade.php b/packages/developer/resources/views/docs/layouts/stacked-layout.blade.php deleted file mode 100644 index 0968feb..0000000 --- a/packages/developer/resources/views/docs/layouts/stacked-layout.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - - -
- Layouts - Stacked Layout -
-
- - - - - - - - - - - - - - @verbatim - - - - - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/navigations/navbar.blade.php b/packages/developer/resources/views/docs/navigations/navbar.blade.php deleted file mode 100644 index 36cd612..0000000 --- a/packages/developer/resources/views/docs/navigations/navbar.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - - -
- Navigations - Navbar -
-
- - - - - - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/navigations/sidebar.blade.php b/packages/developer/resources/views/docs/navigations/sidebar.blade.php deleted file mode 100644 index 34e1256..0000000 --- a/packages/developer/resources/views/docs/navigations/sidebar.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - - -
- Navigations - Sidebar -
-
- - - - - - - - - - - - - - @verbatim - - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/pages/components.blade.php b/packages/developer/resources/views/docs/pages/components.blade.php deleted file mode 100644 index 750772c..0000000 --- a/packages/developer/resources/views/docs/pages/components.blade.php +++ /dev/null @@ -1,130 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Pages - Components -
-
- - - - Experimental feature ahead! - The page builder is currently in development and not final, methods may change over time. - - - - Page components live in the Froxlor\UI\Pages\Components namespace. Combine them to compose fully featured resource screens. - - - - Tabs & Tab - Organise sections of the page into logical groups. Tabs accept props that are passed to nested schema builders. - - - @verbatim - use Froxlor\UI\Pages; - - Pages\Components\Tabs::make('tenants.show.tabs') - ->props(['tenant' => $tenant]) - ->schema([ - Pages\Components\Tab::make('tenants.show.tabs.summary') - ->label(trans('froxlor-core::generic.summary')) - ->schema([ - Pages\Components\Placeholder::make('hostname') - ->label(trans('froxlor-core::generic.hostname')), - ]), - - Pages\Components\Tab::make('tenants.show.tabs.settings') - ->label(trans('froxlor-core::generic.settings')) - ->sort(50) - ->schema([ - Pages\Components\Form::make('tenants.show.tabs.settings.form') - ->schema($this->edit($tenant)->schema()), - ]), - ]); - @endverbatim - - - - - - Relation - Embed a table that reuses your table columns and actions. Perfect for resource-to-resource listings. - - - @verbatim - use Froxlor\UI\Pages; - use Froxlor\UI\Tables\Actions\Action as TableAction; - use Froxlor\UI\Tables\Columns\TextColumn; - - Pages\Components\Relation::make('environments') - ->fetch(route('api.tenants.environments.index', $tenant)) - ->intendedRoute('environments.show', ['environment' => '{id}']) - ->columns([ - TextColumn::make('name') - ->label(trans('froxlor-core::generic.name')) - ->sortable(), - - TextColumn::make('created_at') - ->label(trans('froxlor-core::generic.created_at')) - ->sortable(), - ]) - ->actions([ - TableAction::make('create') - ->label(trans('froxlor-core::generic.create')) - ->href(route('environments.create')) - ->icon('plus'), - ]); - @endverbatim - - - - - - Form - Reuse an existing form schema inline. This keeps validation, translations, and layout aligned across contexts. - - - @verbatim - use Froxlor\UI\Forms; - use Froxlor\UI\Pages; - use Froxlor\UI\Schemas\Components\Section; - - Pages\Components\Form::make('tenants.show.tabs.edit.form') - ->schema([ - Schemas\Components\Section::make('basic') - ->title(trans('froxlor-core::generic.title')) - ->schema([ - Forms\Components\TextInput::make('name') - ->label(trans('froxlor-core::generic.name')) - ->required(), - ]), - ]) - ->actions([ - Forms\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('resources.tenants.index')), - ]); - @endverbatim - - - - - - Placeholder - Display read-only values in the page layout, such as connection details or summary metrics. - - - @verbatim - use Froxlor\Core\Models\Tenant; - use Froxlor\UI\Pages; - - Pages\Components\Placeholder::make('hostname') - ->label(trans('froxlor-core::generic.hostname')) - ->default(fn (Tenant $tenant) => $tenant->primary_node?->hostname); - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/pages/overview.blade.php b/packages/developer/resources/views/docs/pages/overview.blade.php deleted file mode 100644 index e48434b..0000000 --- a/packages/developer/resources/views/docs/pages/overview.blade.php +++ /dev/null @@ -1,65 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Pages - Overview -
-
- - - - Experimental feature ahead! - The page builder is currently in development and not final, methods may change over time. - - - - What a Page Schema Does - Page schemas orchestrate read-focused experiences such as dashboards and resource detail views. They combine layout components, server-side data fetching, and contextual actions so resources like TenantResource can deliver a cohesive overview. - - - - Key Building Blocks - - - - Meta & Context - Provide the information that powers breadcrumbs, headers, and initial state. - - - title(), description(), and teaser() keep the header aligned with the navigation. Use props() to pass computed values into the Vue components rendered within the page schema. - - - - - - Schema Layout - Arrange blocks such as tabs, relations, or embedded forms. - - - Use the Pages\Components collection. Tabs group related information, Relation renders tables inside the page, Form embeds form schemas, and Placeholder displays simple values while reusing the design language. - - - - - - Actions & Navigation - Guide the reader to the next meaningful interaction. - - - Attach contextual call-to-actions via Pages\Actions\Action. Pair fetch() with intendedRoute() so buttons and deep links remain in sync with the resource workflow. - - - - - - - Implementation Tips - - - - Keep schemas small and composable. Extract reusable tab or relation builders to dedicated methods when serving multiple resources (for example NodeResource and UserResource) to ensure consistent UX and make testing easier. - - - -
diff --git a/packages/developer/resources/views/docs/pages/quick-start.blade.php b/packages/developer/resources/views/docs/pages/quick-start.blade.php deleted file mode 100644 index 5ef389e..0000000 --- a/packages/developer/resources/views/docs/pages/quick-start.blade.php +++ /dev/null @@ -1,99 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Pages - Quick Start -
-
- - - - Experimental feature ahead! - The page builder is currently in development and not final, methods may change over time. - - - - Minimal Tenant Overview - The snippet below renders a summary tab plus an edit action. It mirrors the structure used in TenantResource::show. - - - - @verbatim - use Froxlor\UI\Pages; - use Froxlor\UI\Pages\Page; - - return Page::make('tenants.show') - ->props(['tenant' => $tenant]) - ->teaser(trans('froxlor-core::generic.tenant')) - ->title($tenant->name) - ->description('High-level tenant overview') - ->fetch(route('api.tenants.show', $tenant)) - ->schema([ - Pages\Components\Tabs::make('tenants.show.tabs') - ->schema([ - Pages\Components\Tab::make('tenants.show.tabs.summary') - ->label('Summary') - ->schema([ - Pages\Components\Placeholder::make('hostname') - ->label(trans('froxlor-core::generic.hostname')), - ]), - ]), - ]) - ->actions([ - Pages\Actions\Action::make('edit') - ->label(trans('froxlor-core::generic.edit')) - ->intendedRoute('tenants.edit', ['tenant' => '{id}']) - ->icon('pen'), - ]); - @endverbatim - - - - - - Relational Data & Inline Forms - This pattern keeps related resources and inline edit capabilities together. The relation component reuses table columns for consistency, while the embedded form mirrors the create/edit schema. - - - - @verbatim - use Froxlor\UI\Forms; - use Froxlor\UI\Schemas\Components\Section; - use Froxlor\UI\Tables\Columns\TextColumn; - - Pages\Components\Tabs::make('tenants.show.tabs') - ->props(['tenant' => $tenant]) - ->schema([ - Pages\Components\Tab::make('tenants.show.tabs.environments') - ->label('Environments') - ->schema([ - Pages\Components\Relation::make('environments') - ->fetch(route('api.tenants.environments.index', $tenant)) - ->intendedRoute('tenants.edit', ['tenant' => '{id}']) - ->columns([ - TextColumn::make('name') - ->label(trans('froxlor-core::generic.name')), - ]), - ]), - - Pages\Components\Tab::make('tenants.show.tabs.edit') - ->label(trans('froxlor-core::generic.edit')) - ->schema([ - Pages\Components\Form::make('tenants.show.tabs.edit.form') - ->schema([ - Schemas\Components\Section::make('section_a') - ->title(trans('froxlor-core::generic.title')) - ->schema([ - Forms\Components\TextInput::make('name') - ->label(trans('froxlor-core::generic.name')) - ->required(), - ]), - ]), - ]), - ]); - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/schema/overview.blade.php b/packages/developer/resources/views/docs/schema/overview.blade.php deleted file mode 100644 index e866abc..0000000 --- a/packages/developer/resources/views/docs/schema/overview.blade.php +++ /dev/null @@ -1,46 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Schemas - Overview -
-
- - - - Experimental feature ahead! - The schema builders are currently in development and not final, methods may change over time. - - - - Building Blocks - The froxlor UI kit ships three schema builders that cover the most common application surfaces: -
    -
  • Forms – Compose create/edit flows with validation, sections, and reusable input components.
  • -
  • Pages – Assemble read-focused layouts with tabs, relations, and inline forms.
  • -
  • Tables – Define resource listings with sorting, actions, and optional redirects.
  • -
-
- - - Typical Resource Flow - Resource classes return the appropriate schema builder for each endpoint. A standard CRUD resource will expose: -
    -
  • index() – returns a Table schema.
  • -
  • create() / edit() – return a Form schema.
  • -
  • show() – returns a Page schema.
  • -
- Each builder shares conventions for titles, descriptions, and actions, so your navigation and breadcrumbs stay aligned. -
- - - Next Steps - Jump into the detailed guides for the concrete builders: -
    -
  • Forms – conceptual overview, quick start, and component reference.
  • -
  • Pages – orchestration patterns for detail screens.
  • -
  • Tables – guidelines for consistent resource listings.
  • -
-
-
diff --git a/packages/developer/resources/views/docs/tables/components.blade.php b/packages/developer/resources/views/docs/tables/components.blade.php deleted file mode 100644 index 6f0bb1a..0000000 --- a/packages/developer/resources/views/docs/tables/components.blade.php +++ /dev/null @@ -1,55 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Tables - Components -
-
- - - TextColumn - Helper - - - @verbatim - use Froxlor\UI\Tables; - - Tables\Columns\TextColumn::make('name') - ->label('froxlor-core::generic.name') - ->boolean() - ->sortable() - ->searchable() - ->toggleable() - ->formatValue() - ->html(), - @endverbatim - - - - - - IconColumn - Helper - - - @verbatim - use Froxlor\UI\Tables; - - Tables\Columns\IconColumn::make('name') - ->label('froxlor-core::generic.name') - ->trueIcon('circle-check') - ->falseIcon('circle-x') - ->trueVariant('primary') - ->falseVariant('secondary') - ->boolean() - ->sortable() - ->searchable() - ->toggleable(), - @endverbatim - - - - - -
diff --git a/packages/developer/resources/views/docs/tables/quick-start.blade.php b/packages/developer/resources/views/docs/tables/quick-start.blade.php deleted file mode 100644 index e6e8d23..0000000 --- a/packages/developer/resources/views/docs/tables/quick-start.blade.php +++ /dev/null @@ -1,280 +0,0 @@ -{{-- Status: Dev,warning --}} - - -
- Tables - Quick Start -
-
- - - - Experimental feature ahead! - The table builder is currently in development and not final, methods may change over time. - - - - Columns - columns(...) - - - - @verbatim - use Froxlor\UI\Table; - use Froxlor\UI\Tables; - - public function index(): Table - { - return Table::make() - ->columns([ - Tables\Columns\TextColumn::make('name') - ->label('froxlor-core::generic.name') - ->sortable(), - - // ... - ]); - } - @endverbatim - - - - - - Column Actions - columnActions(...) showcases column actions that link to the resource row. - - - - @verbatim - use Froxlor\UI\Table; - use Froxlor\UI\Tables; - - public function index(): Table - { - return Table::make() - ->columns([ - // ... - ]) - ->columnActions([ - Tables\ColumnActions\Action::make('show') - ->label('froxlor-core::generic.show') - ->intendedRoute('resources.nodes.edit', ['node' => '{id}']) - ->icon('eye'), - - // ... - ]); - } - @endverbatim - - - - - - Bulk Actions - bulkActions(...) enables multi-row selection. Actions can either submit the selected row keys as selected[] or execute a server-side handler(...). - - - - @verbatim - use Froxlor\UI\Table; - use Froxlor\UI\Tables; - - public function index(): Table - { - return Table::make() - ->fetch(route('api.nodes.index')) - ->columns([ - // ... - ]) - ->selectionKey('id') - ->bulkActions([ - Tables\Actions\Action::make('delete') - ->label(trans('froxlor-core::generic.delete')) - ->icon('trash') - ->handler(function (array $selected) { - foreach ($selected as $nodeId) { - // delete each row individually here - } - }), - ]); - } - @endverbatim - - - - - - Actions - actions(...) showcases actions that link to other pages. - - - - @verbatim - use Froxlor\UI\Table; - use Froxlor\UI\Tables; - - public function index(): Table - { - return Table::make() - ->columns([ - // ... - ]) - ->actions([ - Tables\Actions\Action::make('create') - ->label('froxlor-core::generic.create') - ->href(route('resources.nodes.create')) - ->visible(fn() => true) - ->icon('plus'), - - // ... - ]); - } - @endverbatim - - - - - - Title - title(...) - - - - @verbatim - use Froxlor\UI\Table; - - public function index(): Table - { - return Table::make() - ->title('froxlor::generic.text') - ->columns([ - // ... - ]); - } - @endverbatim - - - - - - Description - description(...) - - - - @verbatim - use Froxlor\UI\Table; - - public function index(): Table - { - return Table::make() - ->description('froxlor::generic.text') - ->columns([ - // ... - ]); - } - @endverbatim - - - - - - Fetch - fetch(...) - - - - @verbatim - use Froxlor\UI\Table; - - public function index(): Table - { - return Table::make() - ->fetch(route('api.nodes.index')) - ->columns([ - // ... - ]); - } - @endverbatim - - - - - - Filters - filters(...) - - - - Undocumented code - This function has not been documented yet. - - - - - @verbatim - use Froxlor\UI\Table; - - public function index(): Table - { - return Table::make() - ->filters(...) - ->columns([ - // ... - ]); - } - @endverbatim - - - - - - Intended Route - intendedRoute(...) - - - - @verbatim - use Froxlor\UI\Table; - - public function index(): Table - { - return Table::make() - ->intendedRoute('resources.nodes.show', ['node' => '{id}']) - ->columns([ - // ... - ]); - } - @endverbatim - - - - - - Props - props(...) - - - - Undocumented code - This function has not been documented yet. - - - - - @verbatim - use Froxlor\UI\Table; - - public function index(): Table - { - return Table::make() - ->props(...) - ->columns([ - // ... - ]); - } - @endverbatim - - - -
diff --git a/packages/developer/resources/views/docs/utilities/schema.blade.php b/packages/developer/resources/views/docs/utilities/schema.blade.php deleted file mode 100644 index cea1bb4..0000000 --- a/packages/developer/resources/views/docs/utilities/schema.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -{{-- Status: Docs,info --}} - - -
- Utilities - Schema -
-
-
diff --git a/packages/developer/resources/views/index.blade.php b/packages/developer/resources/views/index.blade.php deleted file mode 100644 index 34d97e4..0000000 --- a/packages/developer/resources/views/index.blade.php +++ /dev/null @@ -1,67 +0,0 @@ - - Welcome to the froxlor Development Kit - A collection of reusable components to build fast, consistent, and modern froxlor interfaces. - - - - Prerequisites -
    -
  • PHP 8.5+
  • -
  • Composer
  • -
  • Node.js and npm (for building frontend assets)
  • -
-
- - - - Publish assets - - Publish the UI assets using the following Artisan command: - - php artisan vendor:publish --tag=froxlor-ui-assets --ansi --force - - - - - Troubleshooting -
    -
  • - Assets not loading: - Ensure you have published the assets and cleared any caches. Run php artisan optimize:clear. -
  • -
-
- - - - Quick links -
- Components / Button - Components / Form - Components / Alert - Forms / Overview - Pages / Overview - Tables / Overview -
- Explore more in Components, Layouts, Navigations, and Utilities. -
- - - - Resources -
-
- Official Documentation -
-
- Framework Repository -
-
- Issue Tracker -
-
- Community Chat (Discord) -
-
-
-
diff --git a/packages/developer/routes/web.php b/packages/developer/routes/web.php deleted file mode 100644 index 10e8fe2..0000000 --- a/packages/developer/routes/web.php +++ /dev/null @@ -1,12 +0,0 @@ -name('developers.')->prefix('developers')->group(function () { - Route::get('', [Web\DeveloperController::class, 'index']) - ->name('index'); - - Route::get('docs/{folder}/{page}', [Web\DeveloperController::class, 'docs']) - ->name('docs'); -}); diff --git a/packages/developer/src/Http/Controllers/Controller.php b/packages/developer/src/Http/Controllers/Controller.php deleted file mode 100644 index 55fb5fd..0000000 --- a/packages/developer/src/Http/Controllers/Controller.php +++ /dev/null @@ -1,8 +0,0 @@ -isLocal() && app()->hasDebugModeEnabled()) { - // Routes - $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); - - // Views - $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'froxlor-developer'); - - // User Interface - $this->extendUserInterface(); - } - } - - public function register(): void - { - // - } - - /** - * Register navigation items and other UI related stuff. - */ - private function extendUserInterface(): void - { - UI::push('settings', items: [ - SettingLink::make('developers') - ->label('Developers') - ->route(fn() => route('developers.index')) - ->icon('code') - ->badge('DEV', 'default'), - ]); - - $layouts = $this->getNavigationItems('layouts', __DIR__ . '/../../resources/views/docs/layouts/*.blade.php'); - $navigations = $this->getNavigationItems('navigations', __DIR__ . '/../../resources/views/docs/navigations/*.blade.php'); - $components = $this->getNavigationItems('components', __DIR__ . '/../../resources/views/docs/components/*.blade.php'); - $forms = $this->getNavigationItems('forms', __DIR__ . '/../../resources/views/docs/forms/*.blade.php'); - $pages = $this->getNavigationItems('pages', __DIR__ . '/../../resources/views/docs/pages/*.blade.php'); - $tables = $this->getNavigationItems('tables', __DIR__ . '/../../resources/views/docs/tables/*.blade.php'); - $schema = $this->getNavigationItems('schema', __DIR__ . '/../../resources/views/docs/schema/*.blade.php'); - $utilities = $this->getNavigationItems('utilities', __DIR__ . '/../../resources/views/docs/utilities/*.blade.php'); - - UI::push('developer', items: [ - SidebarLink::make('getting-started') - ->label('Getting started') - ->route(fn() => route('developers.index')) - ->icon('book-open'), - - SidebarLink::make('layouts') - ->label('Layouts') - ->icon('panels-top-left'), - - ...$this->registerNavigationItems($layouts), - - SidebarLink::make('navigations') - ->label('Navigations') - ->icon('panel-left-dashed'), - - ...$this->registerNavigationItems($navigations), - - SidebarLink::make('components') - ->label('Components') - ->badge(count($components)) - ->icon('box'), - - ...$this->registerNavigationItems($components), - - SidebarLink::make('forms') - ->label('Forms') - ->icon('square-pen'), - - ...$this->registerNavigationItems($forms), - - SidebarLink::make('pages') - ->label('Pages') - ->icon('layout-dashboard'), - - ...$this->registerNavigationItems($pages), - - SidebarLink::make('tables') - ->label('Tables') - ->icon('table'), - - ...$this->registerNavigationItems($tables), - - SidebarLink::make('schema') - ->label('Schema') - ->icon('square-chart-gantt'), - - ...$this->registerNavigationItems($schema), - - SidebarLink::make('utilities') - ->label('Utilities') - ->icon('wrench'), - - ...$this->registerNavigationItems($utilities), - ]); - - } - - private function getNavigationItems(string $key, string $path): array - { - $items = array_map(function ($file) use ($key) { - $label = Str::title(str_replace(['-', '.blade', '.php'], ' ', pathinfo($file, PATHINFO_FILENAME))); - $slug = Str::slug(strtolower($label)); - $content = file_get_contents($file); - [$badge, $badgeVariant] = preg_match('/{{--\s*Status\s*:\s*([^,}\r\n]+?)(?:\s*,\s*([^}\r\n]+))?\s*--}}/is', $content, $m) - ? [trim($m[1]), isset($m[2]) && $m[2] !== '' ? trim($m[2]) : 'outline'] - : [null, 'outline']; - - return [ - 'key' => strtolower($key . '.' . $slug), - 'folder' => $key, - 'page' => $slug, - 'label' => $label, - 'badge' => $badge, - 'badgeVariant' => $badgeVariant, - ]; - }, glob($path)); - - return $items; - } - - private function registerNavigationItems(array $items): array - { - $registry = []; - - foreach ($items as $item) { - $registry[] = SidebarLink::make($item['key']) - ->label($item['label']) - ->route(fn() => route('developers.docs', [ - 'folder' => $item['folder'], - 'page' => $item['page'], - ])) - ->badge($item['badge'], $item['badgeVariant']); - } - - return $registry; - } -} diff --git a/packages/domain/.gitignore b/packages/domain/.gitignore deleted file mode 100644 index 8a33e12..0000000 --- a/packages/domain/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules -/vendor -composer.lock -package-lock.json diff --git a/packages/domain/composer.json b/packages/domain/composer.json deleted file mode 100644 index c103ba6..0000000 --- a/packages/domain/composer.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://getcomposer.org/schema.json", - "name": "froxlor/domain", - "type": "library", - "description": "The froxlor domain package.", - "keywords": [ - "froxlor", - "management", - "panel" - ], - "homepage": "https://www.froxlor.org", - "license": "proprietary", - "authors": [ - { - "name": "Michael Kaufmann", - "email": "d00p@froxlor.org", - "role": "Lead Developer" - }, - { - "name": "Maurice Preuß", - "email": "envoyr@froxlor.org", - "role": "Developer" - } - ], - "require": { - "php": "^8.5", - "froxlor/core": "*", - "froxlor/ui": "*", - "illuminate/support": "^12.0" - }, - "suggest": { - "froxlor/dns": "Allows managing of DNS zones for domains", - "froxlor/web": "Allows domains to be used for websites / online services", - "froxlor/mail": "Allows domains to be used for email addresses / accounts", - "froxlor/domain-register-pro": "Allows ordering of domains via registrar" - }, - "autoload": { - "psr-4": { - "Froxlor\\Domain\\": "src/", - "Froxlor\\Domain\\Database\\Seeders\\": "database/seeders/" - } - }, - "extra": { - "froxlor": { - "type": "package" - }, - "laravel": { - "providers": [ - "Froxlor\\Domain\\Providers\\EventServiceProvider", - "Froxlor\\Domain\\Providers\\FroxlorDomainServiceProvider" - ] - } - } -} diff --git a/packages/domain/database/migrations/0001_01_03_000001_create_domains_table.php b/packages/domain/database/migrations/0001_01_03_000001_create_domains_table.php deleted file mode 100644 index fffd760..0000000 --- a/packages/domain/database/migrations/0001_01_03_000001_create_domains_table.php +++ /dev/null @@ -1,33 +0,0 @@ -ulid('id')->primary(); - $table->string('domain')->index()->unique(); - $table->jsonb('properties')->nullable(); - $table->ulid('parent_domain_id')->nullable()->constrained('domains', 'id')->cascadeOnDelete(); - $table->foreignUlid('tenant_id')->constrained()->cascadeOnDelete(); - $table->foreignUlid('environment_id')->nullable()->constrained()->cascadeOnDelete(); - $table->foreignUlid('node_id')->nullable()->constrained()->cascadeOnDelete(); - $table->timestamps(); - $table->softDeletes(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('domains'); - } -}; diff --git a/packages/domain/database/seeders/DatabaseSeeder.php b/packages/domain/database/seeders/DatabaseSeeder.php deleted file mode 100644 index 59e30e0..0000000 --- a/packages/domain/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,53 +0,0 @@ -call($this->seederClasses()); - Audit::log('The domain seeder classes have been seeded.'); - - // call development/test fixture seeders - if (SeedProfile::includesDevelopmentData()) { - $this->call($this->testingSeederClasses()); - Audit::log('The ' . SeedProfile::developmentDataLabel() . ' domain seeder classes have been seeded.'); - } - } - - /** - * All essential seeders required for a minimal production installation. - * - * @return array> - */ - private function seederClasses(): array - { - return [ - // - ]; - } - - /** - * All non-production fixture seeders used by local development and tests. - * - * @return array> - */ - private function testingSeederClasses(): array - { - return [ - Testing\DomainTableSeeder::class - ]; - } -} diff --git a/packages/domain/database/seeders/Testing/DomainTableSeeder.php b/packages/domain/database/seeders/Testing/DomainTableSeeder.php deleted file mode 100644 index ec40ee5..0000000 --- a/packages/domain/database/seeders/Testing/DomainTableSeeder.php +++ /dev/null @@ -1,45 +0,0 @@ -create([ - 'key' => 'domains', - 'name' => 'Domains', - 'type' => 'environment', - 'model_type' => Domain::class, - ]); - - // add to unlimited plan to be available for super-admin - $plan = Plan::query()->where('name', 'Unlimited')->first(); - $plan->resources()->attach($domain_resource, [ - 'limit' => -1 - ]); - - /** - * @todo this is for debugging/development purposes - */ - $tenant = Tenant::query()->first(); - foreach (['dev', 'local', 'test', 'prod', 'tld'] as $domaintld) { - $tenant->domains()->create([ - 'domain' => 'example.'.$domaintld, - ]); - } - } -} diff --git a/packages/domain/lang/en/generic.php b/packages/domain/lang/en/generic.php deleted file mode 100644 index 5f7b2b0..0000000 --- a/packages/domain/lang/en/generic.php +++ /dev/null @@ -1,7 +0,0 @@ - 'Domains', - 'domain' => 'Domain', - 'info' => 'Info', -]; diff --git a/packages/domain/routes/api.php b/packages/domain/routes/api.php deleted file mode 100644 index 506ff34..0000000 --- a/packages/domain/routes/api.php +++ /dev/null @@ -1,9 +0,0 @@ -prefix('api')->name('api.')->group(function () { - Route::apiResource('domains', Api\DomainController::class); - Route::apiResource('tenants.domains', Api\Tenant\DomainController::class); - Route::apiResource('tenants.environments.domains', Api\Tenant\Environment\DomainController::class); -}); diff --git a/packages/domain/routes/web.php b/packages/domain/routes/web.php deleted file mode 100644 index 3c6257e..0000000 --- a/packages/domain/routes/web.php +++ /dev/null @@ -1,12 +0,0 @@ -group(function () { - Route::prefix('resources')->name('resources.')->group(function () { - Route::resource('domains', Web\DomainController::class); - }); - Route::resource('tenants.domains', Web\Tenant\DomainController::class)->only(['index', 'create', 'show', 'edit']); - Route::resource('tenants.environments.domains', Web\Tenant\Environment\DomainController::class)->only(['show', 'edit']); -}); diff --git a/packages/domain/src/Http/Controllers/Api/DomainController.php b/packages/domain/src/Http/Controllers/Api/DomainController.php deleted file mode 100644 index 2f8f596..0000000 --- a/packages/domain/src/Http/Controllers/Api/DomainController.php +++ /dev/null @@ -1,71 +0,0 @@ -validatedResource(); - // create resource - $domain = Domain::query()->create($domainData); - // build up validated data for others - $eventData = $request->validatedEvent(); - // throw event that resource was created and append validated data - event(new CoreEvents\Api\ResourceCreated($domain, $eventData)); - - // return resource - return Response::jsonResource($domain->refresh()); - } - - /** - * Display the specified resource. - */ - public function show(Request $request, Domain $domain) - { - return Response::jsonResource($domain->load(['tenant', 'environment'])); - } - - /** - * Update the specified resource in storage. - */ - public function update(UpdateDomainRequest $request, Domain $domain) - { - $domain->update($request->validated()); - - return Response::jsonResource($domain->refresh()); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Request $request, Domain $domain) - { - $domain->delete(); - - return response()->noContent(); - } -} diff --git a/packages/domain/src/Http/Controllers/Api/Tenant/DomainController.php b/packages/domain/src/Http/Controllers/Api/Tenant/DomainController.php deleted file mode 100644 index 072122f..0000000 --- a/packages/domain/src/Http/Controllers/Api/Tenant/DomainController.php +++ /dev/null @@ -1,77 +0,0 @@ -domains()); - } - - /** - * Store a newly created resource in storage. - */ - public function store(StoreTenantDomainRequest $request, Tenant $tenant) - { - // get validated data only for ourselves - $domainData = $request->validatedResource(); - // fixed values - $domainData['tenant_id'] = $tenant->id; - // create resource - $domain = Domain::query()->create($domainData); - // build up validated data for others - $eventData = $request->validatedEvent(); - // throw event that resource was created and append validated data - event(new CoreEvents\Api\ResourceCreated($domain, $eventData)); - - // return resource - return Response::jsonResource($domain->refresh()); - } - - /** - * Display the specified resource. - */ - public function show(Request $request, Tenant $tenant, Domain $domain) - { - return Response::jsonResource($domain->load(['environment'])); - } - - /** - * Update the specified resource in storage. - */ - public function update(UpdateDomainRequest $request, Tenant $tenant, Domain $domain) - { - $domainData = $request->validated(); - unset($domainData['tenant_id']); - - $domain->update($domainData); - - return Response::jsonResource($domain->refresh()); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Request $request, Tenant $tenant, Domain $domain) - { - $domain->delete(); - - return response()->noContent(); - } -} diff --git a/packages/domain/src/Http/Controllers/Api/Tenant/Environment/DomainController.php b/packages/domain/src/Http/Controllers/Api/Tenant/Environment/DomainController.php deleted file mode 100644 index bfbc69c..0000000 --- a/packages/domain/src/Http/Controllers/Api/Tenant/Environment/DomainController.php +++ /dev/null @@ -1,79 +0,0 @@ -domains()); - } - - /** - * Store a newly created resource in storage. - */ - public function store(StoreTenantEnvironmentDomainRequest $request, Tenant $tenant, Environment $environment) - { - // get validated data only for ourselves - $domainData = $request->validatedResource(); - // fixed values - $domainData['tenant_id'] = $tenant->id; - $domainData['environment_id'] = $environment->id; - // create resource - $domain = Domain::query()->create($domainData); - // build up validated data for others - $eventData = $request->validatedEvent(); - // throw event that resource was created and append validated data - event(new CoreEvents\Api\ResourceCreated($domain, $eventData)); - - // return resource - return Response::jsonResource($domain->refresh()); - } - - /** - * Display the specified resource. - */ - public function show(Request $request, Tenant $tenant, Environment $environment, Domain $domain) - { - return Response::jsonResource($domain); - } - - /** - * Update the specified resource in storage. - */ - public function update(UpdateDomainRequest $request, Tenant $tenant, Environment $environment, Domain $domain) - { - $domainData = $request->validated(); - unset($domainData['tenant_id'], $domainData['environment_id']); - - $domain->update($domainData); - - return Response::jsonResource($domain->refresh()); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Request $request, Tenant $tenant, Environment $environment, Domain $domain) - { - $domain->delete(); - - return response()->noContent(); - } -} diff --git a/packages/domain/src/Http/Controllers/Web/DomainController.php b/packages/domain/src/Http/Controllers/Web/DomainController.php deleted file mode 100644 index 5186ccc..0000000 --- a/packages/domain/src/Http/Controllers/Web/DomainController.php +++ /dev/null @@ -1,31 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'domain' => 'required|string|unique:domains,domain', - 'properties' => 'sometimes|array', - 'parent_domain_id' => 'sometimes|exists:domains,id', - 'tenant_id' => 'required|exists:tenants,id', - 'environment_id' => 'sometimes|exists:environments,id', - 'node_id' => 'required_with:environment_id|exists:nodes,id', - ]; - } - - public function withEventRules(): array - { - return [Domain::class, 'store']; - } -} diff --git a/packages/domain/src/Http/Requests/Tenant/Environment/StoreTenantEnvironmentDomainRequest.php b/packages/domain/src/Http/Requests/Tenant/Environment/StoreTenantEnvironmentDomainRequest.php deleted file mode 100644 index 2ead260..0000000 --- a/packages/domain/src/Http/Requests/Tenant/Environment/StoreTenantEnvironmentDomainRequest.php +++ /dev/null @@ -1,37 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'domain' => 'required|string', - 'properties' => 'sometimes|array', - 'parent_domain_id' => 'sometimes|exists:domains,id', - 'node_id' => 'required|exists:nodes,id', - ]; - } - - public function withEventRules(): array - { - return [Domain::class, 'environmentStore']; - } -} diff --git a/packages/domain/src/Http/Requests/Tenant/StoreTenantDomainRequest.php b/packages/domain/src/Http/Requests/Tenant/StoreTenantDomainRequest.php deleted file mode 100644 index 0059863..0000000 --- a/packages/domain/src/Http/Requests/Tenant/StoreTenantDomainRequest.php +++ /dev/null @@ -1,38 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'domain' => 'required|string', - 'properties' => 'sometimes|array', - 'parent_domain_id' => 'sometimes|exists:domains,id', - 'environment_id' => 'sometimes|exists:environments,id', - 'node_id' => 'required_with:environment_id|exists:nodes,id', - ]; - } - - public function withEventRules(): array - { - return [Domain::class, 'tenantStore']; - } -} diff --git a/packages/domain/src/Http/Requests/UpdateDomainRequest.php b/packages/domain/src/Http/Requests/UpdateDomainRequest.php deleted file mode 100644 index 832cde1..0000000 --- a/packages/domain/src/Http/Requests/UpdateDomainRequest.php +++ /dev/null @@ -1,38 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'domain' => [ - 'sometimes', - 'string', - Rule::unique('domains', 'domain')->ignore($this->domain), - ], - 'properties' => 'sometimes|array', - 'parent_domain_id' => 'sometimes|nullable|exists:domains,id', - 'tenant_id' => 'sometimes|exists:tenants,id', - 'environment_id' => 'sometimes|nullable|exists:environments,id', - 'node_id' => 'sometimes|nullable|exists:nodes,id', - ]; - } -} diff --git a/packages/domain/src/Listeners/SeedDatabase.php b/packages/domain/src/Listeners/SeedDatabase.php deleted file mode 100644 index 3670ab7..0000000 --- a/packages/domain/src/Listeners/SeedDatabase.php +++ /dev/null @@ -1,14 +0,0 @@ -databaseSeeder->call(DatabaseSeeder::class); - } -} diff --git a/packages/domain/src/Models/Domain.php b/packages/domain/src/Models/Domain.php deleted file mode 100644 index 5f941a4..0000000 --- a/packages/domain/src/Models/Domain.php +++ /dev/null @@ -1,54 +0,0 @@ - 'array' - ]; - - public function environment(): BelongsTo - { - return $this->belongsTo(Environment::class); - } - - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - - public function node(): BelongsTo - { - return $this->belongsTo(Node::class); - } -} diff --git a/packages/domain/src/Providers/EventServiceProvider.php b/packages/domain/src/Providers/EventServiceProvider.php deleted file mode 100644 index 0f163a0..0000000 --- a/packages/domain/src/Providers/EventServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -> - */ - protected $listen = [ - CoreEvents\DatabaseSeeded::class => [ - Listeners\SeedDatabase::class, - ] - ]; -} diff --git a/packages/domain/src/Providers/FroxlorDomainServiceProvider.php b/packages/domain/src/Providers/FroxlorDomainServiceProvider.php deleted file mode 100644 index 34b790c..0000000 --- a/packages/domain/src/Providers/FroxlorDomainServiceProvider.php +++ /dev/null @@ -1,107 +0,0 @@ -loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - - // Routes - $this->loadRoutesFrom(__DIR__ . '/../../routes/api.php'); - $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); - - // Language - $this->loadTranslationsFrom(__DIR__ . '/../../lang', 'froxlor-domain'); - - // Policies, Events etc. hier registrieren - - Relation::morphMap([ - 'domains' => Models\Domain::class, - ]); - - // Relations - $this->extendRelations(); - - // User Interface - $this->extendUserInterface(); - } - - public function register(): void - { - // - } - - private function extendRelations(): void - { - // model relations - Tenant::resolveRelationUsing('domains', function (Tenant $tenant) { - return $tenant->hasMany(Models\Domain::class); - }); - Environment::resolveRelationUsing('domains', function (Environment $environment) { - return $environment->hasMany(Models\Domain::class); - }); - - // ui view relations - $domainIndexSchema = DomainSchema::indexSchema(); - Schemas\Schema::stack('tenants.show.tabs', fn(Tenant $tenant) => Schemas\Components\Tab::make('tenants.show.tabs.domains') - ->label(trans('froxlor-domain::generic.domains')) - ->sort(5000) - ->components([ - \Froxlor\UI\Schemas\Components\Relation::make('tenants.show.relations.domains') - ->fetch(route('api.tenants.domains.index', $tenant)) - ->intendedRoute('tenants.domains.show', ['tenant' => $tenant->id, 'domain' => '{id}']) - ->columns($domainIndexSchema['columns']) - ->actions($domainIndexSchema['actions']) - ]) - ); - Schemas\Schema::stack('tenants.environments.show.tabs', fn(Tenant $tenant, Environment $environment) => Schemas\Components\Tab::make('tenants.environments.show.tabs.domains') - ->label(trans('froxlor-domain::generic.domains')) - ->sort(5000) - ->components([ - Schemas\Components\Relation::make('tenants.environments.show.relations.domains') - ->fetch(route('api.tenants.environments.domains.index', [$tenant, $environment])) - ->intendedRoute('tenants.environments.domains.show', ['tenant' => $tenant->id, 'environment' => $environment->id, 'domain' => '{id}']) - ->columns($domainIndexSchema['columns']) - ->actions($domainIndexSchema['actions']) - ]) - ); - } - - private function extendUserInterface(): void - { - $tenantId = fn() => ($tenant = request()->route('tenant') ?? request()->query('tenant')) - ? ($tenant instanceof Tenant ? $tenant->id : $tenant) - : null; - - UI::push('sub-sidebar', items: [ - SidebarLink::make('domains') - ->label(trans('froxlor-domain::generic.domains')) - ->route(fn() => route('resources.domains.index')) - ->active(fn() => request()->routeIs('resources.domains.*')) - ->visible(fn() => request()->routeIs('resources.*')) - ->icon('globe'), - ]); - - UI::push('tenant-sidebar', items: [ - SidebarLink::make('tenant-domains', 20) - ->label(trans('froxlor-domain::generic.domains')) - ->route(fn() => $tenantId() ? route('tenants.domains.index', ['tenant' => $tenantId()]) : '#') - ->active(fn() => request()->routeIs('tenants.domains.*')) - ->visible(fn() => (bool)$tenantId()) - ->icon('globe'), - ]); - } -} diff --git a/packages/domain/src/Resources/DomainResource.php b/packages/domain/src/Resources/DomainResource.php deleted file mode 100644 index c09b228..0000000 --- a/packages/domain/src/Resources/DomainResource.php +++ /dev/null @@ -1,109 +0,0 @@ -title(trans('froxlor-domain::generic.domains')) - ->description(trans('froxlor-core::generic.show_resource_list', ['resource' => trans('froxlor-domain::generic.domains')])) - ->fetch(route('api.domains.index')) - ->intendedRoute('resources.domains.show', ['domain' => '{id}']) - ->columns([ - Tables\Columns\TextColumn::make('domain') - ->label(trans('froxlor-domain::generic.domain')) - ->sortable(), - - Tables\Columns\TextColumn::make('info') - ->label(trans('froxlor-domain::generic.info')), - - Tables\Columns\TextColumn::make('created_at') - ->label(trans('froxlor-core::generic.created_at')) - ->sortable(), - ]) - ->actions([ - Tables\Actions\Action::make('create') - ->label(trans('froxlor-core::generic.create')) - ->href(route('resources.domains.create')) - ->icon('plus'), - ]); - } - - public function show(Domain $domain): Schema - { - return Schema::make() - ->title(trans('froxlor-core::generic.view_resource')) - ->description(trans('froxlor-core::generic.view_resource')) - ->fetch(route('api.domains.show', $domain)) - ->push(route('api.domains.update', $domain), 'PUT') - ->intendedRoute('resources.domains.index') - ->components($this->domainFormSchema()) - ->actions([ - \Froxlor\UI\Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('resources.domains.index')), - ]); - } - - public function create(): Schema - { - return Schema::make() - ->title(trans('froxlor-core::generic.create_resource')) - ->description(trans('froxlor-core::generic.create_resource')) - ->push(route('api.domains.store')) - ->intendedRoute('resources.domains.index') - ->components($this->domainFormSchema()) - ->actions([ - \Froxlor\UI\Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('resources.domains.index')), - ]); - } - - public function edit(Domain $domain): Schema - { - return $this->create() - ->fetch(route('api.domains.show', $domain)) - ->push(route('api.domains.update', $domain), 'PUT') - ->intendedRoute('resources.domains.show', ['domain' => $domain]); - } - - private function domainFormSchema(): array - { - return [ - Section::make('main') - ->title(trans('froxlor-core::generic.title')) - ->components([ - \Froxlor\UI\Forms\Components\TextInput::make('domain') - ->label(trans('froxlor-domain::generic.domain')) - ->required(), - - \Froxlor\UI\Forms\Components\TextInput::make('parent_domain_id') - ->label('Parent Domain ID'), - ]), - - Section::make('relations') - ->title(trans('froxlor-core::generic.title')) - ->components([ - \Froxlor\UI\Forms\Components\TextInput::make('tenant_id') - ->label('Tenant ID') - ->required(), - - \Froxlor\UI\Forms\Components\TextInput::make('environment_id') - ->label('Environment ID'), - - \Froxlor\UI\Forms\Components\TextInput::make('node_id') - ->label('Node ID'), - ]), - ]; - } -} diff --git a/packages/domain/src/Resources/EnvironmentDomainResource.php b/packages/domain/src/Resources/EnvironmentDomainResource.php deleted file mode 100644 index 3fad5ad..0000000 --- a/packages/domain/src/Resources/EnvironmentDomainResource.php +++ /dev/null @@ -1,117 +0,0 @@ -title(trans('froxlor-domain::generic.domains')) - ->description(trans('froxlor-core::generic.show_resource_list', ['resource' => trans('froxlor-domain::generic.domains')])) - ->fetch(route('api.tenants.environments.domains.index', ['tenant' => $tenant, 'environment' => $environment])) - ->intendedRoute('tenants.environments.domains.show', ['tenant' => $tenant, 'environment' => $environment, 'domain' => '{id}']) - ->columns([ - Tables\Columns\TextColumn::make('domain') - ->label(trans('froxlor-domain::generic.domain')) - ->sortable(), - - Tables\Columns\TextColumn::make('info') - ->label(trans('froxlor-domain::generic.info')), - - Tables\Columns\TextColumn::make('created_at') - ->label(trans('froxlor-core::generic.created_at')) - ->sortable(), - ]) - ->actions([ - Tables\Actions\Action::make('create') - ->label(trans('froxlor-core::generic.create')) - ->href(route('tenants.environments.domains.create', [ - 'tenant' => $tenant, - 'environment' => $environment, - ])) - ->icon('plus'), - ]); - } - - public function show(Tenant $tenant, Environment $environment, Domain $domain): Schema - { - return Schema::make() - ->title('title') - ->description('description') - ->fetch(route('api.tenants.environments.domains.show', ['tenant' => $tenant, 'environment' => $environment, 'domain' => $domain])) - ->intendedRoute('tenants.environments.domains.edit', ['tenant' => $tenant->id, 'environment' => $environment->id, 'domain' => $domain->id]) - ->components([ - \Froxlor\UI\Forms\Components\Dump::make('demo') - ->default(fn() => $domain->toArray()), - ]); - } - - public function create(Tenant $tenant, Environment $environment): Schema - { - return Schema::make() - ->title(trans('froxlor-core::generic.create_resource')) - ->description(trans('froxlor-core::generic.create_resource')) - ->push(route('api.tenants.environments.domains.store', [ - 'tenant' => $tenant, - 'environment' => $environment, - ])) - ->intendedRoute('tenants.environments.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - ]) - ->components($this->domainFormSchema()) - ->actions([ - \Froxlor\UI\Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('tenants.environments.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - ])), - ]); - } - - public function edit(Tenant $tenant, Environment $environment, Domain $domain): Schema - { - return $this->create($tenant, $environment) - ->fetch(route('api.tenants.environments.domains.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'domain' => $domain, - ])) - ->push(route('api.tenants.environments.domains.update', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'domain' => $domain, - ]), 'PUT') - ->intendedRoute('tenants.environments.domains.show', [ - 'tenant' => $tenant, - 'environment' => $environment, - 'domain' => $domain, - ]); - } - - private function domainFormSchema(): array - { - return [ - \Froxlor\UI\Forms\Components\TextInput::make('domain') - ->label(trans('froxlor-domain::generic.domain')) - ->required(), - - \Froxlor\UI\Forms\Components\TextInput::make('parent_domain_id') - ->label('Parent Domain ID'), - - \Froxlor\UI\Forms\Components\TextInput::make('node_id') - ->label('Node ID') - ->required(), - ]; - } -} diff --git a/packages/domain/src/Resources/Schemas/DomainSchema.php b/packages/domain/src/Resources/Schemas/DomainSchema.php deleted file mode 100644 index b5cb933..0000000 --- a/packages/domain/src/Resources/Schemas/DomainSchema.php +++ /dev/null @@ -1,65 +0,0 @@ - [ - Tables\Columns\TextColumn::make('domain') - ->label(trans('froxlor-domain::generic.domain')) - ->sortable(), - - Tables\Columns\TextColumn::make('properties') - ->label(trans('froxlor-domain::generic.properties')) - ->sortable(), - - Tables\Columns\TextColumn::make('created_at') - ->label(trans('froxlor-core::generic.created_at')) - ->sortable(), - ], - 'actions' => [ - Tables\Actions\Action::make('create') - ->label(trans('froxlor-core::generic.create')) - ->href(route('resources.domains.create')) - ->icon('plus'), - ] - ]; - } - - public static function showSchema(Domain $domain): array - { - return [ - 'columns' => [ - Section::make('main') - ->title(trans('froxlor-core::generic.title')) - ->components([ - \Froxlor\UI\Forms\Components\TextInput::make('name') - ->label(trans('froxlor-core::generic.name')) - ->required(), - - \Froxlor\UI\Forms\Components\TextInput::make('description') - ->label(trans('froxlor-core::generic.description')), - ]), - - Section::make('resources') - ->title(trans('froxlor-core::generic.resources')) - ->components([ - \Froxlor\UI\Forms\Components\Dump::make('demo') - ->default(fn() => $domain->domain_vhosts()->get()->toArray()), - ]), - ], - 'actions' => [ - \Froxlor\UI\Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('domains.index')), - ] - ]; - } -} diff --git a/packages/domain/src/Resources/TenantDomainResource.php b/packages/domain/src/Resources/TenantDomainResource.php deleted file mode 100644 index cba5d59..0000000 --- a/packages/domain/src/Resources/TenantDomainResource.php +++ /dev/null @@ -1,94 +0,0 @@ -title(trans('froxlor-domain::generic.domains')) - ->description(trans('froxlor-core::generic.show_resource_list', ['resource' => trans('froxlor-domain::generic.domains')])) - ->fetch(route('api.tenants.domains.index', ['tenant' => $tenant->id])) - ->intendedRoute('tenants.domains.show', ['tenant' => $tenant->id, 'domain' => '{id}']) - ->columns([ - Tables\Columns\TextColumn::make('domain') - ->label(trans('froxlor-domain::generic.domain')) - ->sortable(), - - Tables\Columns\TextColumn::make('info') - ->label(trans('froxlor-domain::generic.info')), - - Tables\Columns\TextColumn::make('created_at') - ->label(trans('froxlor-core::generic.created_at')) - ->sortable(), - ]) - ->actions([ - Tables\Actions\Action::make('create') - ->label(trans('froxlor-core::generic.create')) - ->href(route('tenants.domains.create', ['tenant' => $tenant])) - ->icon('plus'), - ]); - } - - public function show(Tenant $tenant, Domain $domain): Schema - { - return Schema::make() - ->title('title') - ->description('description') - ->fetch(route('api.tenants.domains.show', ['tenant' => $tenant, 'domain' => $domain])) - ->intendedRoute('tenants.domains.edit', ['tenant' => $tenant, 'domain' => $domain]) - ->components([ - \Froxlor\UI\Forms\Components\Dump::make('demo') - ->default(fn() => $domain->toArray()), - ]); - } - - public function create(Tenant $tenant): Schema - { - return Schema::make() - ->title(trans('froxlor-core::generic.create_resource')) - ->description(trans('froxlor-core::generic.create_resource')) - ->push(route('api.tenants.domains.store', ['tenant' => $tenant])) - ->intendedRoute('tenants.show', ['tenant' => $tenant]) - ->components($this->domainFormSchema()) - ->actions([ - \Froxlor\UI\Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('tenants.show', ['tenant' => $tenant])), - ]); - } - - public function edit(Tenant $tenant, Domain $domain): Schema - { - return $this->create($tenant) - ->fetch(route('api.tenants.domains.show', ['tenant' => $tenant, 'domain' => $domain])) - ->push(route('api.tenants.domains.update', ['tenant' => $tenant, 'domain' => $domain]), 'PUT') - ->intendedRoute('tenants.domains.show', ['tenant' => $tenant, 'domain' => $domain]); - } - - private function domainFormSchema(): array - { - return [ - \Froxlor\UI\Forms\Components\TextInput::make('domain') - ->label(trans('froxlor-domain::generic.domain')) - ->required(), - - \Froxlor\UI\Forms\Components\TextInput::make('parent_domain_id') - ->label('Parent Domain ID'), - - \Froxlor\UI\Forms\Components\TextInput::make('environment_id') - ->label('Environment ID'), - - \Froxlor\UI\Forms\Components\TextInput::make('node_id') - ->label('Node ID'), - ]; - } -} diff --git a/packages/ftp/.gitignore b/packages/ftp/.gitignore deleted file mode 100644 index 8a33e12..0000000 --- a/packages/ftp/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules -/vendor -composer.lock -package-lock.json diff --git a/packages/ftp/composer.json b/packages/ftp/composer.json deleted file mode 100644 index fb1d394..0000000 --- a/packages/ftp/composer.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://getcomposer.org/schema.json", - "name": "froxlor/ftp", - "type": "library", - "description": "The froxlor ftp package.", - "keywords": [ - "froxlor", - "management", - "panel" - ], - "homepage": "https://www.froxlor.org", - "license": "proprietary", - "authors": [ - { - "name": "Michael Kaufmann", - "email": "d00p@froxlor.org", - "role": "Lead Developer" - }, - { - "name": "Maurice Preuß", - "email": "envoyr@froxlor.org", - "role": "Developer" - } - ], - "require": { - "php": "^8.5", - "froxlor/core": "*", - "froxlor/ui": "*", - "illuminate/support": "^12.0" - }, - "autoload": { - "psr-4": { - "Froxlor\\Ftp\\": "src/" - } - }, - "extra": { - "froxlor": { - "type": "package" - }, - "laravel": { - "providers": [ - "Froxlor\\Ftp\\Providers\\FroxlorFtpServiceProvider" - ] - } - } -} diff --git a/packages/ftp/database/migrations/0002_01_01_000001_create_ftp_services_table.php b/packages/ftp/database/migrations/0002_01_01_000001_create_ftp_services_table.php deleted file mode 100644 index 42a43f0..0000000 --- a/packages/ftp/database/migrations/0002_01_01_000001_create_ftp_services_table.php +++ /dev/null @@ -1,41 +0,0 @@ -ulid('id')->primary(); - $table->foreignUlid('node_id')->constrained()->cascadeOnDelete(); - $table->string('name'); - $table->string('driver')->default('vsftpd'); - $table->string('listen_address')->default('0.0.0.0'); - $table->unsignedInteger('port')->default(21); - $table->boolean('allow_local_users')->default(true); - $table->boolean('allow_write')->default(true); - $table->boolean('chroot_local_users')->default(true); - $table->boolean('allow_writable_chroot')->default(true); - $table->unsignedInteger('passive_min_port')->default(40000); - $table->unsignedInteger('passive_max_port')->default(40100); - $table->string('status')->default('defined'); - $table->timestamp('installed_at')->nullable(); - $table->timestamp('configured_at')->nullable(); - $table->timestamp('last_checked_at')->nullable(); - $table->boolean('is_reachable')->default(false); - $table->text('last_error')->nullable(); - $table->json('properties')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->unique('node_id'); - }); - } - - public function down(): void - { - Schema::dropIfExists('ftp_services'); - } -}; diff --git a/packages/ftp/resources/views/scripts/ftp-service/configure/vsftpd/debian13.blade.php b/packages/ftp/resources/views/scripts/ftp-service/configure/vsftpd/debian13.blade.php deleted file mode 100644 index 73d378a..0000000 --- a/packages/ftp/resources/views/scripts/ftp-service/configure/vsftpd/debian13.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cat > /etc/vsftpd.conf <<'EOF' -anonymous_enable=NO -listen=YES -listen_address={{ $ftpService->listen_address }} -listen_port={{ $ftpService->port }} -local_enable={{ $ftpService->allow_local_users ? 'YES' : 'NO' }} -write_enable={{ $ftpService->allow_write ? 'YES' : 'NO' }} -chroot_local_user={{ $ftpService->chroot_local_users ? 'YES' : 'NO' }} -allow_writeable_chroot={{ $ftpService->allow_writable_chroot ? 'YES' : 'NO' }} -pasv_enable=YES -pasv_min_port={{ $ftpService->passive_min_port }} -pasv_max_port={{ $ftpService->passive_max_port }} -EOF diff --git a/packages/ftp/resources/views/scripts/ftp-service/configure/vsftpd/ubuntu2404.blade.php b/packages/ftp/resources/views/scripts/ftp-service/configure/vsftpd/ubuntu2404.blade.php deleted file mode 100644 index 73d378a..0000000 --- a/packages/ftp/resources/views/scripts/ftp-service/configure/vsftpd/ubuntu2404.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cat > /etc/vsftpd.conf <<'EOF' -anonymous_enable=NO -listen=YES -listen_address={{ $ftpService->listen_address }} -listen_port={{ $ftpService->port }} -local_enable={{ $ftpService->allow_local_users ? 'YES' : 'NO' }} -write_enable={{ $ftpService->allow_write ? 'YES' : 'NO' }} -chroot_local_user={{ $ftpService->chroot_local_users ? 'YES' : 'NO' }} -allow_writeable_chroot={{ $ftpService->allow_writable_chroot ? 'YES' : 'NO' }} -pasv_enable=YES -pasv_min_port={{ $ftpService->passive_min_port }} -pasv_max_port={{ $ftpService->passive_max_port }} -EOF diff --git a/packages/ftp/resources/views/scripts/ftp-service/install/vsftpd/debian13.blade.php b/packages/ftp/resources/views/scripts/ftp-service/install/vsftpd/debian13.blade.php deleted file mode 100644 index 06b12f8..0000000 --- a/packages/ftp/resources/views/scripts/ftp-service/install/vsftpd/debian13.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y vsftpd -systemctl enable vsftpd -systemctl start vsftpd diff --git a/packages/ftp/resources/views/scripts/ftp-service/install/vsftpd/ubuntu2404.blade.php b/packages/ftp/resources/views/scripts/ftp-service/install/vsftpd/ubuntu2404.blade.php deleted file mode 100644 index 06b12f8..0000000 --- a/packages/ftp/resources/views/scripts/ftp-service/install/vsftpd/ubuntu2404.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y vsftpd -systemctl enable vsftpd -systemctl start vsftpd diff --git a/packages/ftp/routes/api.php b/packages/ftp/routes/api.php deleted file mode 100644 index 78b8c23..0000000 --- a/packages/ftp/routes/api.php +++ /dev/null @@ -1,22 +0,0 @@ -prefix('api')->name('api.')->group(function () { - Route::get('nodes/{node}/ftp-service', [Api\Node\FtpServiceController::class, 'show']) - ->name('nodes.ftp-service.show'); - Route::post('nodes/{node}/ftp-service', [Api\Node\FtpServiceController::class, 'store']) - ->name('nodes.ftp-service.store'); - Route::put('nodes/{node}/ftp-service', [Api\Node\FtpServiceController::class, 'update']) - ->name('nodes.ftp-service.update'); - Route::patch('nodes/{node}/ftp-service', [Api\Node\FtpServiceController::class, 'update']) - ->name('nodes.ftp-service.patch'); - Route::post('nodes/{node}/ftp-service/install', [Api\Node\FtpServiceController::class, 'install']) - ->name('nodes.ftp-service.install'); - Route::post('nodes/{node}/ftp-service/configure', [Api\Node\FtpServiceController::class, 'configure']) - ->name('nodes.ftp-service.configure'); - Route::post('nodes/{node}/ftp-service/check', [Api\Node\FtpServiceController::class, 'check']) - ->name('nodes.ftp-service.check'); - Route::delete('nodes/{node}/ftp-service', [Api\Node\FtpServiceController::class, 'destroy']) - ->name('nodes.ftp-service.destroy'); -}); diff --git a/packages/ftp/routes/web.php b/packages/ftp/routes/web.php deleted file mode 100644 index 46902b6..0000000 --- a/packages/ftp/routes/web.php +++ /dev/null @@ -1,22 +0,0 @@ -group(function () { - Route::prefix('resources/nodes/{node}')->name('resources.nodes.')->group(function () { - Route::get('ftp-service', [Web\Node\FtpServiceController::class, 'show']) - ->name('ftp-service.show'); - Route::get('ftp-service/create', [Web\Node\FtpServiceController::class, 'create']) - ->name('ftp-service.create'); - Route::get('ftp-service/edit', [Web\Node\FtpServiceController::class, 'edit']) - ->name('ftp-service.edit'); - Route::post('ftp-service/install', [Web\Node\FtpServiceController::class, 'install']) - ->name('ftp-service.install'); - Route::post('ftp-service/configure', [Web\Node\FtpServiceController::class, 'configure']) - ->name('ftp-service.configure'); - Route::post('ftp-service/check', [Web\Node\FtpServiceController::class, 'check']) - ->name('ftp-service.check'); - }); -}); diff --git a/packages/ftp/src/Http/Controllers/Api/Node/FtpServiceController.php b/packages/ftp/src/Http/Controllers/Api/Node/FtpServiceController.php deleted file mode 100644 index 123959e..0000000 --- a/packages/ftp/src/Http/Controllers/Api/Node/FtpServiceController.php +++ /dev/null @@ -1,108 +0,0 @@ -ftpService, 404); - - return Response::jsonResource($node->ftpService); - } - - public function store(StoreFtpServiceRequest $request, Node $node) - { - abort_if($node->ftpService()->exists(), 422, 'FTP service already configured for this node.'); - - $ftpService = $node->ftpService()->create($this->normalizedData($request->validated())); - - return Response::jsonResource($ftpService); - } - - public function update(UpdateFtpServiceRequest $request, Node $node) - { - $ftpService = $this->ftpService($node); - $ftpService->update($this->normalizedData($request->validated(), false)); - - return Response::jsonResource($ftpService->fresh()); - } - - public function install(Request $request, Node $node) - { - $ftpService = $this->ftpService($node); - $ftpService->forceFill([ - 'status' => 'install_queued', - 'last_error' => null, - ])->save(); - - dispatch(new InstallFtpService($ftpService->fresh())); - - return Response::jsonResource($ftpService->fresh()); - } - - public function configure(Request $request, Node $node) - { - $ftpService = $this->ftpService($node); - $ftpService->forceFill([ - 'status' => 'configure_queued', - 'last_error' => null, - ])->save(); - - dispatch(new ConfigureFtpService($ftpService->fresh())); - - return Response::jsonResource($ftpService->fresh()); - } - - public function check(Request $request, Node $node) - { - $ftpService = $this->ftpService($node); - $ftpService->forceFill([ - 'status' => 'check_queued', - 'last_error' => null, - ])->save(); - - dispatch(new CheckFtpService($ftpService->fresh())); - - return Response::jsonResource($ftpService->fresh()); - } - - public function destroy(Request $request, Node $node) - { - $ftpService = $this->ftpService($node); - $ftpService->delete(); - - return response()->noContent(); - } - - private function normalizedData(array $data, bool $withDefaults = true): array - { - if ($withDefaults) { - $data['driver'] = $data['driver'] ?? 'vsftpd'; - $data['status'] = $data['status'] ?? 'defined'; - $data['allow_local_users'] = $data['allow_local_users'] ?? true; - $data['allow_write'] = $data['allow_write'] ?? true; - $data['chroot_local_users'] = $data['chroot_local_users'] ?? true; - $data['allow_writable_chroot'] = $data['allow_writable_chroot'] ?? true; - } - - return $data; - } - - private function ftpService(Node $node) - { - abort_if(! $node->ftpService, 404); - - return $node->ftpService; - } -} diff --git a/packages/ftp/src/Http/Controllers/Controller.php b/packages/ftp/src/Http/Controllers/Controller.php deleted file mode 100644 index ca3fab3..0000000 --- a/packages/ftp/src/Http/Controllers/Controller.php +++ /dev/null @@ -1,8 +0,0 @@ -ftpService) { - return UI::render(FtpServiceResource::class, 'create', [$node]); - } - - return UI::render(FtpServiceResource::class, 'show', [$node]); - } - - public function create(Node $node) - { - return UI::render(FtpServiceResource::class, 'create', [$node]); - } - - public function edit(Node $node) - { - if (! $node->ftpService) { - return UI::render(FtpServiceResource::class, 'create', [$node]); - } - - return UI::render(FtpServiceResource::class, 'edit', [$node]); - } - - public function install(Node $node): RedirectResponse - { - abort_if(! $node->ftpService, 404); - - $node->ftpService->forceFill([ - 'status' => 'install_queued', - 'last_error' => null, - ])->save(); - - dispatch(new InstallFtpService($node->ftpService->fresh())); - - return redirect()->route('resources.nodes.ftp-service.show', ['node' => $node]); - } - - public function configure(Node $node): RedirectResponse - { - abort_if(! $node->ftpService, 404); - - $node->ftpService->forceFill([ - 'status' => 'configure_queued', - 'last_error' => null, - ])->save(); - - dispatch(new ConfigureFtpService($node->ftpService->fresh())); - - return redirect()->route('resources.nodes.ftp-service.show', ['node' => $node]); - } - - public function check(Node $node): RedirectResponse - { - abort_if(! $node->ftpService, 404); - - $node->ftpService->forceFill([ - 'status' => 'check_queued', - 'last_error' => null, - ])->save(); - - dispatch(new CheckFtpService($node->ftpService->fresh())); - - return redirect()->route('resources.nodes.ftp-service.show', ['node' => $node]); - } -} diff --git a/packages/ftp/src/Http/Requests/StoreFtpServiceRequest.php b/packages/ftp/src/Http/Requests/StoreFtpServiceRequest.php deleted file mode 100644 index 54e8f27..0000000 --- a/packages/ftp/src/Http/Requests/StoreFtpServiceRequest.php +++ /dev/null @@ -1,29 +0,0 @@ - 'required|string|max:255', - 'driver' => 'nullable|string|max:50', - 'listen_address' => 'required|string|max:255', - 'port' => 'required|integer|min:1|max:65535', - 'allow_local_users' => 'nullable|boolean', - 'allow_write' => 'nullable|boolean', - 'chroot_local_users' => 'nullable|boolean', - 'allow_writable_chroot' => 'nullable|boolean', - 'passive_min_port' => 'required|integer|min:1|max:65535', - 'passive_max_port' => 'required|integer|min:1|max:65535', - ]; - } -} diff --git a/packages/ftp/src/Http/Requests/UpdateFtpServiceRequest.php b/packages/ftp/src/Http/Requests/UpdateFtpServiceRequest.php deleted file mode 100644 index 53e61ac..0000000 --- a/packages/ftp/src/Http/Requests/UpdateFtpServiceRequest.php +++ /dev/null @@ -1,29 +0,0 @@ - 'sometimes|string|max:255', - 'driver' => 'sometimes|nullable|string|max:50', - 'listen_address' => 'sometimes|string|max:255', - 'port' => 'sometimes|integer|min:1|max:65535', - 'allow_local_users' => 'sometimes|boolean', - 'allow_write' => 'sometimes|boolean', - 'chroot_local_users' => 'sometimes|boolean', - 'allow_writable_chroot' => 'sometimes|boolean', - 'passive_min_port' => 'sometimes|integer|min:1|max:65535', - 'passive_max_port' => 'sometimes|integer|min:1|max:65535', - ]; - } -} diff --git a/packages/ftp/src/Jobs/FtpService/CheckFtpService.php b/packages/ftp/src/Jobs/FtpService/CheckFtpService.php deleted file mode 100644 index 8e04b49..0000000 --- a/packages/ftp/src/Jobs/FtpService/CheckFtpService.php +++ /dev/null @@ -1,22 +0,0 @@ -check($this->ftpService->fresh()); - } -} diff --git a/packages/ftp/src/Jobs/FtpService/ConfigureFtpService.php b/packages/ftp/src/Jobs/FtpService/ConfigureFtpService.php deleted file mode 100644 index 3b4c217..0000000 --- a/packages/ftp/src/Jobs/FtpService/ConfigureFtpService.php +++ /dev/null @@ -1,22 +0,0 @@ -configure($this->ftpService->fresh()); - } -} diff --git a/packages/ftp/src/Jobs/FtpService/InstallFtpService.php b/packages/ftp/src/Jobs/FtpService/InstallFtpService.php deleted file mode 100644 index 5ad92ae..0000000 --- a/packages/ftp/src/Jobs/FtpService/InstallFtpService.php +++ /dev/null @@ -1,22 +0,0 @@ -install($this->ftpService->fresh()); - } -} diff --git a/packages/ftp/src/Models/FtpService.php b/packages/ftp/src/Models/FtpService.php deleted file mode 100644 index 4607f34..0000000 --- a/packages/ftp/src/Models/FtpService.php +++ /dev/null @@ -1,59 +0,0 @@ - 'boolean', - 'allow_write' => 'boolean', - 'chroot_local_users' => 'boolean', - 'allow_writable_chroot' => 'boolean', - 'installed_at' => 'datetime', - 'configured_at' => 'datetime', - 'last_checked_at' => 'datetime', - 'is_reachable' => 'boolean', - 'properties' => 'encrypted:array', - ]; - - public function node(): BelongsTo - { - return $this->belongsTo(Node::class); - } -} diff --git a/packages/ftp/src/Providers/FroxlorFtpServiceProvider.php b/packages/ftp/src/Providers/FroxlorFtpServiceProvider.php deleted file mode 100644 index 24b5155..0000000 --- a/packages/ftp/src/Providers/FroxlorFtpServiceProvider.php +++ /dev/null @@ -1,101 +0,0 @@ -loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - - // Routes - $this->loadRoutesFrom(__DIR__ . '/../../routes/api.php'); - $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); - - // Views - $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'froxlor-ftp'); - - // Relations - $this->extendRelations(); - - // Node UI extensions - $this->extendUserInterface(); - - // Platform-specific provisioning scripts - $this->registerScripts(); - } - - public function register(): void - { - // - } - - private function extendRelations(): void - { - Node::resolveRelationUsing('ftpService', function (Node $node) { - return $node->hasOne(FtpService::class); - }); - } - - private function extendUserInterface(): void - { - Schema::stack('resources.nodes.show.tabs', function (Node $node) { - return Schemas\Components\Tab::make('resources.nodes.show.tabs.ftp_service') - ->sort(1600) - ->label('FTP service') - ->components([ - $node->ftpService - ? app(FtpServiceResource::class)->show($node) - : app(FtpServiceResource::class)->create($node), - ]); - }); - } - - private function registerScripts(): void - { - foreach (['debian13' => 'debian@13', 'ubuntu2404' => 'ubuntu@24.04'] as $slug => $platformKey) { - ScriptRegistry::register(new ScriptDefinition( - feature: 'ftp-service', - action: 'install', - platformKey: $platformKey, - view: "froxlor-ftp::scripts.ftp-service.install.vsftpd.{$slug}", - variant: 'vsftpd', - targetPath: '/usr/local/lib/froxlor/ftp-service/install-vsftpd.sh', - runAsRoot: true, - ownership: ['root', 'root'], - executable: true, - executeAfterWrite: true, - package: 'ftp', - )); - - ScriptRegistry::register(new ScriptDefinition( - feature: 'ftp-service', - action: 'configure', - platformKey: $platformKey, - view: "froxlor-ftp::scripts.ftp-service.configure.vsftpd.{$slug}", - variant: 'vsftpd', - targetPath: '/usr/local/lib/froxlor/ftp-service/configure-vsftpd.sh', - runAsRoot: true, - ownership: ['root', 'root'], - executable: true, - executeAfterWrite: true, - reloadCommands: [ - 'vsftpd -version || true', - 'systemctl restart vsftpd', - 'systemctl is-active vsftpd', - ], - package: 'ftp', - )); - } - } -} diff --git a/packages/ftp/src/Resources/Nodes/FtpServiceResource.php b/packages/ftp/src/Resources/Nodes/FtpServiceResource.php deleted file mode 100644 index 02d53a0..0000000 --- a/packages/ftp/src/Resources/Nodes/FtpServiceResource.php +++ /dev/null @@ -1,69 +0,0 @@ -teaser(trans('froxlor-core::generic.node')) - ->title($node->name . ' - FTP service') - ->description(trans('froxlor-core::generic.create_resource')) - ->push(route('api.nodes.ftp-service.store', ['node' => $node])) - ->intendedRoute('resources.nodes.ftp-service.show', ['node' => $node]) - ->components(FtpServiceForm::schema()) - ->actions([ - Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('resources.nodes.show', ['node' => $node])), - ]); - } - - public function show(Node $node): Schema - { - $ftpService = $node->ftpService; - - abort_if(! $ftpService, 404); - - return Schema::make('resources.nodes.ftp-service.show') - ->props([ - 'node' => $node, - 'ftpService' => $ftpService, - ]) - ->teaser(trans('froxlor-core::generic.node')) - ->title($node->name . ' - FTP service') - ->description(trans('froxlor-core::generic.view_resource')) - ->fetch(route('api.nodes.ftp-service.show', ['node' => $node])) - ->components(FtpServiceView::schema($node, $ftpService)) - ->actions(FtpServiceView::actions($node)); - } - - public function edit(Node $node): Schema - { - $ftpService = $node->ftpService; - - abort_if(! $ftpService, 404); - - return Schema::make('resources.nodes.ftp-service.edit') - ->teaser(trans('froxlor-core::generic.node')) - ->title($node->name . ' - FTP service') - ->description(trans('froxlor-core::generic.edit_resource')) - ->fetch(route('api.nodes.ftp-service.show', ['node' => $node])) - ->push(route('api.nodes.ftp-service.update', ['node' => $node]), 'PUT') - ->intendedRoute('resources.nodes.ftp-service.show', ['node' => $node]) - ->components(FtpServiceForm::schema()) - ->actions([ - Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('resources.nodes.ftp-service.show', ['node' => $node])), - ]); - } -} diff --git a/packages/ftp/src/Resources/Nodes/Relations/FtpServices/Schemas/FtpServiceForm.php b/packages/ftp/src/Resources/Nodes/Relations/FtpServices/Schemas/FtpServiceForm.php deleted file mode 100644 index 21d9be6..0000000 --- a/packages/ftp/src/Resources/Nodes/Relations/FtpServices/Schemas/FtpServiceForm.php +++ /dev/null @@ -1,70 +0,0 @@ -title('FTP service') - ->components([ - Forms\Components\TextInput::make('name') - ->label(trans('froxlor-core::generic.name')) - ->required(), - Forms\Components\Select::make('driver') - ->label(trans('froxlor-core::generic.driver')) - ->options([ - 'vsftpd' => 'vsftpd', - ]), - Forms\Components\TextInput::make('listen_address') - ->label('Listen address') - ->required(), - Forms\Components\TextInput::make('port') - ->label(trans('froxlor-core::generic.port')) - ->integer() - ->required(), - ]), - Section::make('ftp_service_access') - ->title('Access') - ->components([ - Forms\Components\Select::make('allow_local_users') - ->label('Allow local users') - ->options([ - 1 => trans('froxlor-core::generic.yes'), - 0 => trans('froxlor-core::generic.no'), - ]), - Forms\Components\Select::make('allow_write') - ->label('Allow write') - ->options([ - 1 => trans('froxlor-core::generic.yes'), - 0 => trans('froxlor-core::generic.no'), - ]), - Forms\Components\Select::make('chroot_local_users') - ->label('Chroot local users') - ->options([ - 1 => trans('froxlor-core::generic.yes'), - 0 => trans('froxlor-core::generic.no'), - ]), - Forms\Components\Select::make('allow_writable_chroot') - ->label('Allow writable chroot') - ->options([ - 1 => trans('froxlor-core::generic.yes'), - 0 => trans('froxlor-core::generic.no'), - ]), - Forms\Components\TextInput::make('passive_min_port') - ->label('Passive min port') - ->integer() - ->required(), - Forms\Components\TextInput::make('passive_max_port') - ->label('Passive max port') - ->integer() - ->required(), - ]), - ]; - } -} diff --git a/packages/ftp/src/Resources/Nodes/Relations/FtpServices/Schemas/FtpServiceView.php b/packages/ftp/src/Resources/Nodes/Relations/FtpServices/Schemas/FtpServiceView.php deleted file mode 100644 index af82f4a..0000000 --- a/packages/ftp/src/Resources/Nodes/Relations/FtpServices/Schemas/FtpServiceView.php +++ /dev/null @@ -1,45 +0,0 @@ -label(trans('froxlor-core::generic.name')) - ->default(fn() => $ftpService->name), - Schemas\Components\Text::make('driver') - ->label('Driver') - ->default(fn() => $ftpService->driver), - Schemas\Components\Text::make('listen_address') - ->label('Listen address') - ->default(fn() => $ftpService->listen_address), - Schemas\Components\Text::make('port') - ->label('Port') - ->default(fn() => (string) $ftpService->port), - Schemas\Components\Text::make('passive_range') - ->label('Passive range') - ->default(fn() => $ftpService->passive_min_port . ' - ' . $ftpService->passive_max_port), - ...NodeServiceSchema::standardStatusFields($ftpService), - ], - ), - ]; - } - - public static function actions(Node $node): array - { - return NodeServiceActions::make($node, 'resources.nodes.ftp-service'); - } -} diff --git a/packages/ftp/src/Services/FtpServiceLifecycle.php b/packages/ftp/src/Services/FtpServiceLifecycle.php deleted file mode 100644 index a0d5570..0000000 --- a/packages/ftp/src/Services/FtpServiceLifecycle.php +++ /dev/null @@ -1,209 +0,0 @@ -forceFill([ - 'status' => 'installing', - 'last_error' => null, - ])->save(); - - try { - $adapter = $ftpService->node->adapter(); - $platform = $ftpService->node->platform(); - $definition = ScriptRegistry::resolve('ftp-service', 'install', $ftpService->node, $ftpService->driver); - - if (! $platform->supported) { - $this->fail($ftpService, 'Unsupported platform: ' . $platform->key()); - return; - } - - if (! $definition) { - $this->fail($ftpService, 'No install script registered for platform ' . $platform->key() . ' and driver ' . $ftpService->driver); - return; - } - - if (! $adapter->isConnected()) { - $this->fail($ftpService, 'Unable to connect to node.'); - return; - } - - $probe = $this->binaryProbeCommand($ftpService->driver); - $version = trim((string) $adapter->exec([$probe])); - $properties = $ftpService->properties ?? []; - $properties['lifecycle']['install_probe'] = $probe; - $properties['lifecycle']['install_result'] = $version; - $properties['lifecycle']['install_script_view'] = $definition->view; - $installPlan = $this->deployer->plan($definition, [ - 'ftpService' => $ftpService, - 'node' => $ftpService->node, - 'platform' => $platform, - ]); - $properties['lifecycle']['install_plan'] = $installPlan->toArray(); - - if ($version !== '') { - $ftpService->forceFill([ - 'installed_at' => now(), - 'status' => 'installed', - 'properties' => $properties, - 'last_error' => null, - ])->save(); - - return; - } - - $ftpService->forceFill([ - 'status' => 'install_pending', - 'properties' => $properties, - 'last_error' => 'FTP service binary was not found. Review install deployment plan.', - ])->save(); - } catch (Throwable $exception) { - $this->fail($ftpService, $exception->getMessage()); - } - } - - public function configure(FtpService $ftpService): void - { - $ftpService->forceFill([ - 'status' => 'configuring', - 'last_error' => null, - ])->save(); - - try { - $adapter = $ftpService->node->adapter(); - $platform = $ftpService->node->platform(); - $definition = ScriptRegistry::resolve('ftp-service', 'configure', $ftpService->node, $ftpService->driver); - - if (! $platform->supported) { - $this->fail($ftpService, 'Unsupported platform: ' . $platform->key()); - return; - } - - if (! $definition) { - $this->fail($ftpService, 'No configure script registered for platform ' . $platform->key() . ' and driver ' . $ftpService->driver); - return; - } - - if (! $adapter->isConnected()) { - $this->fail($ftpService, 'Unable to connect to node.'); - return; - } - - if ($ftpService->passive_min_port > $ftpService->passive_max_port) { - $this->fail($ftpService, 'Passive port range is invalid.'); - return; - } - - $properties = $ftpService->properties ?? []; - $properties['lifecycle']['configured_listen_address'] = $ftpService->listen_address; - $properties['lifecycle']['configured_port'] = $ftpService->port; - $properties['lifecycle']['configured_passive_range'] = [ - $ftpService->passive_min_port, - $ftpService->passive_max_port, - ]; - $properties['lifecycle']['configure_script_view'] = $definition->view; - $configurePlan = $this->deployer->plan($definition, [ - 'ftpService' => $ftpService, - 'node' => $ftpService->node, - 'platform' => $platform, - ]); - $properties['lifecycle']['configure_plan'] = $configurePlan->toArray(); - - $this->deployer->apply($ftpService->node, $configurePlan); - - $ftpService->forceFill([ - 'configured_at' => now(), - 'status' => 'configured', - 'properties' => $properties, - 'last_error' => null, - ])->save(); - } catch (Throwable $exception) { - $this->fail($ftpService, $exception->getMessage()); - } - } - - public function check(FtpService $ftpService): void - { - $ftpService->forceFill([ - 'status' => 'checking', - 'last_error' => null, - ])->save(); - - try { - $adapter = $ftpService->node->adapter(); - - if (! $adapter->isConnected()) { - $ftpService->forceFill([ - 'status' => 'unreachable', - 'is_reachable' => false, - 'last_checked_at' => now(), - 'last_error' => 'Unable to connect to node.', - ])->save(); - return; - } - - $serviceProbe = $this->serviceProbeCommand($ftpService->driver); - $serviceState = trim((string) $adapter->exec([$serviceProbe])); - $version = trim((string) $adapter->exec([$this->binaryProbeCommand($ftpService->driver)])); - - $properties = $ftpService->properties ?? []; - $properties['lifecycle']['service_probe'] = $serviceProbe; - $properties['lifecycle']['service_state'] = $serviceState; - $properties['lifecycle']['version'] = $version; - - $isReachable = $serviceState === 'active' || $serviceState === 'running'; - - $payload = [ - 'status' => $isReachable ? 'ready' : 'unreachable', - 'is_reachable' => $isReachable, - 'last_checked_at' => now(), - 'properties' => $properties, - 'last_error' => $isReachable ? null : 'FTP service is not active on the node.', - ]; - - if ($version !== '' && ! $ftpService->installed_at) { - $payload['installed_at'] = now(); - } - - $ftpService->forceFill($payload)->save(); - } catch (Throwable $exception) { - $this->fail($ftpService, $exception->getMessage()); - } - } - - private function fail(FtpService $ftpService, string $message): void - { - $ftpService->forceFill([ - 'status' => 'error', - 'last_error' => $message, - ])->save(); - } - - private function binaryProbeCommand(string $driver): string - { - return match ($driver) { - 'vsftpd' => 'vsftpd -version 2>&1 || vsftpd -v 2>&1 || true', - default => 'true', - }; - } - - private function serviceProbeCommand(string $driver): string - { - return match ($driver) { - 'vsftpd' => 'systemctl is-active vsftpd || true', - default => 'true', - }; - } -} diff --git a/packages/mail/.gitignore b/packages/mail/.gitignore deleted file mode 100644 index 8a33e12..0000000 --- a/packages/mail/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules -/vendor -composer.lock -package-lock.json diff --git a/packages/mail/composer.json b/packages/mail/composer.json deleted file mode 100644 index ec1482b..0000000 --- a/packages/mail/composer.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "https://getcomposer.org/schema.json", - "name": "froxlor/mail", - "type": "library", - "description": "The froxlor mail package.", - "keywords": [ - "froxlor", - "management", - "panel" - ], - "homepage": "https://www.froxlor.org", - "license": "proprietary", - "authors": [ - { - "name": "Michael Kaufmann", - "email": "d00p@froxlor.org", - "role": "Lead Developer" - }, - { - "name": "Maurice Preuß", - "email": "envoyr@froxlor.org", - "role": "Developer" - } - ], - "require": { - "php": "^8.5", - "froxlor/domain": "*", - "illuminate/support": "^12.0" - }, - "autoload": { - "psr-4": { - "Froxlor\\Mail\\": "src/", - "Froxlor\\Mail\\Database\\Seeders\\": "database/seeders/" - } - }, - "extra": { - "froxlor": { - "type": "package" - }, - "laravel": { - "providers": [ - "Froxlor\\Mail\\Providers\\EventServiceProvider", - "Froxlor\\Mail\\Providers\\FroxlorMailServiceProvider" - ] - } - } -} diff --git a/packages/mail/database/migrations/0001_01_05_000001_create_mail_addresses_table.php b/packages/mail/database/migrations/0001_01_05_000001_create_mail_addresses_table.php deleted file mode 100644 index 16a025f..0000000 --- a/packages/mail/database/migrations/0001_01_05_000001_create_mail_addresses_table.php +++ /dev/null @@ -1,38 +0,0 @@ -ulid('id')->primary(); - $table->foreignUlid('domain_id')->constrained()->cascadeOnDelete(); - $table->string('address')->unique(); - $table->string('destination')->nullable(); - $table->string('description')->nullable(); - $table->boolean('is_catchall')->default(false); - $table->decimal('spam_tag_level')->default(7.00); - $table->boolean('rewrite_subject')->default(true); - $table->decimal('spam_kill_level')->default(15.00); - $table->boolean('bypass_spam')->default(false); - $table->boolean('policy_greylist')->default(true); - - $table->timestamps(); - $table->softDeletes(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('mail_addresses'); - } -}; diff --git a/packages/mail/database/migrations/0001_01_05_000002_create_mail_accounts_table.php b/packages/mail/database/migrations/0001_01_05_000002_create_mail_accounts_table.php deleted file mode 100644 index 6a1d7a7..0000000 --- a/packages/mail/database/migrations/0001_01_05_000002_create_mail_accounts_table.php +++ /dev/null @@ -1,38 +0,0 @@ -ulid('id')->primary(); - $table->foreignUlid('mail_address_id')->constrained()->cascadeOnDelete(); - $table->string('username')->unique(); - $table->string('password'); - $table->unsignedInteger('uid'); - $table->unsignedInteger('gid'); - $table->string('homedir'); - $table->string('maildir'); - $table->boolean('smtp_enabled')->default(true); - $table->boolean('pop3_enabled')->default(true); - $table->boolean('imap_enabled')->default(true); - - $table->timestamps(); - $table->softDeletes(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('mail_accounts'); - } -}; diff --git a/packages/mail/database/seeders/DatabaseSeeder.php b/packages/mail/database/seeders/DatabaseSeeder.php deleted file mode 100644 index 40d8a20..0000000 --- a/packages/mail/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,53 +0,0 @@ -call($this->seederClasses()); - Audit::log('The mail seeder classes have been seeded.'); - - // call development/test fixture seeders - if (SeedProfile::includesDevelopmentData()) { - $this->call($this->testingSeederClasses()); - Audit::log('The ' . SeedProfile::developmentDataLabel() . ' mail seeder classes have been seeded.'); - } - } - - /** - * All essential seeders required for a minimal production installation. - * - * @return array> - */ - private function seederClasses(): array - { - return [ - // - ]; - } - - /** - * All non-production fixture seeders used by local development and tests. - * - * @return array> - */ - private function testingSeederClasses(): array - { - return [ - Testing\MailTableSeeder::class - ]; - } -} diff --git a/packages/mail/database/seeders/Testing/MailTableSeeder.php b/packages/mail/database/seeders/Testing/MailTableSeeder.php deleted file mode 100644 index fa34230..0000000 --- a/packages/mail/database/seeders/Testing/MailTableSeeder.php +++ /dev/null @@ -1,72 +0,0 @@ -create([ - 'key' => 'mailaddresses', - 'name' => 'Mail addresses', - 'type' => 'environment', - 'model_type' => MailAddress::class, - ]); - $mailAccResource = Resource::query()->create([ - 'key' => 'mailaccounts', - 'name' => 'Mail accounts', - 'type' => 'environment', - 'model_type' => MailAccount::class, - ]); - - // add to unlimited plan to be available for super-admin - $plan = Plan::query()->where('name', 'Unlimited')->first(); - $plan->resources()->attach($mailAddrResource, [ - 'limit' => -1 - ]); - $plan->resources()->attach($mailAccResource, [ - 'limit' => -1 - ]); - - // introduce our settings - Setting::add('mail.enabled', true, true, 'boolean'); - - // node settings - Node::addTypeSetting('mail.enabled', false, false, 'boolean'); - - /** - * @todo this is for debugging/development purposes - */ - $domain = Domain::query()->where('domain', 'example.dev')->first(); - $env = $domain->tenant->environments()->first(); - $node = $env->nodes()->first(); - $domain->update([ - 'properties->mail->enabled' => true, - ]); - - // node specific settings - $node->addSetting('mail.enabled', true, true, 'boolean'); - - $mailAddr = MailAddress::query()->create([ - 'domain_id' => $domain->id, - 'address' => 'test@example.dev', - ]); - - } -} diff --git a/packages/mail/routes/api.php b/packages/mail/routes/api.php deleted file mode 100644 index 3f91a1f..0000000 --- a/packages/mail/routes/api.php +++ /dev/null @@ -1,8 +0,0 @@ -prefix('api')->name('api.')->group(function () { - Route::apiResource('tenants.environments.domains.mail', Api\Tenant\Environment\MailController::class); - Route::apiResource('tenants.environments.domains.mail.account', Api\Tenant\Environment\MailAccountController::class)->except(['index']); -}); diff --git a/packages/mail/routes/web.php b/packages/mail/routes/web.php deleted file mode 100644 index 004d16b..0000000 --- a/packages/mail/routes/web.php +++ /dev/null @@ -1,7 +0,0 @@ -group(function () { - -}); diff --git a/packages/mail/src/Http/Controllers/Api/Tenant/Environment/MailAccountController.php b/packages/mail/src/Http/Controllers/Api/Tenant/Environment/MailAccountController.php deleted file mode 100644 index 7f40b02..0000000 --- a/packages/mail/src/Http/Controllers/Api/Tenant/Environment/MailAccountController.php +++ /dev/null @@ -1,71 +0,0 @@ -userHasResourceAvailable($request->user(), MailAccount::class)) { - // return resource - return Response::jsonResource(new CreateAccount()->execute($request, $mailAddress, $environment)); - } - abort(403); - } - - /** - * Update the specified resource in storage. - */ - public function update(UpdateMailAccountRequest $request, Tenant $tenant, Environment $environment, Domain $domain, MailAddress $mailAddress, MailAccount $mailAccount) - { - return Response::jsonResource(new UpdateAccount()->execute($request, $mailAccount)); - } - - /** - * Remove the specified resource from storage. - * - * @throws InvalidResourceException - */ - public function destroy(Request $request, Tenant $tenant, Environment $environment, Domain $domain, MailAddress $mailAddress, MailAccount $mailAccount) - { - Resource::removeEnvironmentUsage($environment, $mailAccount); - - $removedAccount = clone $mailAccount; - $mailAccount->delete(); - - event(new CoreEvents\Api\ResourceDeleted($removedAccount, [])); - return response()->noContent(); - } -} diff --git a/packages/mail/src/Http/Controllers/Api/Tenant/Environment/MailController.php b/packages/mail/src/Http/Controllers/Api/Tenant/Environment/MailController.php deleted file mode 100644 index 490ca1a..0000000 --- a/packages/mail/src/Http/Controllers/Api/Tenant/Environment/MailController.php +++ /dev/null @@ -1,86 +0,0 @@ -mail_addresses()); - } - - /** - * Store a newly created resource in storage. - * - * @throws UnknownTenantUserException - * @throws UnknownEnvironmentUserException - * @throws ResourceNotFoundException - * @throws ResourceLimitException - * @throws InvalidResourceException - */ - public function store(StoreMailAddressRequest $request, Tenant $tenant, Environment $environment, Domain $domain) - { - if ($environment->userHasResourceAvailable($request->user(), MailAddress::class)) { - // return resource - return Response::jsonResource(new CreateMail()->execute($request, $domain, $environment)); - } - abort(403); - } - - /** - * Update the specified resource in storage. - * - */ - public function update(UpdateMailAddressRequest $request, Tenant $tenant, Environment $environment, Domain $domain, MailAddress $mailAddress) - { - return Response::jsonResource(new UpdateMail()->execute($request, $mailAddress)); - } - - /** - * Remove the specified resource from storage. - * - * @throws InvalidResourceException - */ - public function destroy(Request $request, Tenant $tenant, Environment $environment, Domain $domain, MailAddress $mailAddress) - { - Resource::removeEnvironmentUsage($environment, $mailAddress); - - $removedAddress = clone $mailAddress; - $removedAccount = $mailAddress->mailAccount()->exists() ? clone $mailAddress->mailAccount : null; - - if (!is_null($removedAccount)) { - Resource::removeEnvironmentUsage($environment, $mailAddress->mailAccount); - } - $mailAddress->delete(); - - event(new CoreEvents\Api\ResourceDeleted($removedAddress, [])); - event(new CoreEvents\Api\ResourceDeleted($removedAccount, [])); - - return response()->noContent(); - } -} diff --git a/packages/mail/src/Http/Requests/StoreMailAccountRequest.php b/packages/mail/src/Http/Requests/StoreMailAccountRequest.php deleted file mode 100644 index cf93444..0000000 --- a/packages/mail/src/Http/Requests/StoreMailAccountRequest.php +++ /dev/null @@ -1,39 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'username' => 'required|string|unique:mail_accounts,username', - 'password' => 'required|string', - 'smtp_enabled' => 'sometimes|boolean', - 'pop3_enabled' => 'sometimes|boolean', - 'imap_enabled' => 'sometimes|boolean', - 'quota' => 'sometimes|integer', - ]; - } - - public function withEventRules(): array - { - return [MailAccount::class, 'store']; - } -} diff --git a/packages/mail/src/Http/Requests/StoreMailAddressRequest.php b/packages/mail/src/Http/Requests/StoreMailAddressRequest.php deleted file mode 100644 index 86d7997..0000000 --- a/packages/mail/src/Http/Requests/StoreMailAddressRequest.php +++ /dev/null @@ -1,58 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'address' => 'required|email|unique:mail_addresses,address', - 'description' => 'nullable|string', - 'is_catchall' => 'sometimes|boolean', - function ($attribute, $value, $fail) { - if ($value) { - $exists = MailAddress::query()->where('domain_id', (int)$this->input('domain_id')) - ->where('is_catchall', true) - ->exists(); - - if ($exists) { - $fail('You have already defined a catchall address for this domain.'); - } - } - }, - 'rewrite_subject' => 'sometimes|boolean', - 'bypass_spam' => 'sometimes|boolean', - 'policy_greylist' => 'sometimes|boolean', - 'spam_tag_level' => 'sometimes|decimal:8,2', - 'spam_kill_level' => 'sometimes', 'decimal:8,2', - function ($attribute, $value, $fail) { - $tag = $this->input('spam_tag_level'); - if ($tag !== null && (float)$value <= (float)$tag) { - $fail('Spam kill level must be greater than the spam tag level.'); - } - }, - ]; - } - - public function withEventRules(): array - { - return [MailAddress::class, 'store']; - } -} diff --git a/packages/mail/src/Http/Requests/UpdateMailAccountRequest.php b/packages/mail/src/Http/Requests/UpdateMailAccountRequest.php deleted file mode 100644 index 6914d9a..0000000 --- a/packages/mail/src/Http/Requests/UpdateMailAccountRequest.php +++ /dev/null @@ -1,38 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'password' => 'sometimes|string', - 'smtp_enabled' => 'sometimes|boolean', - 'pop3_enabled' => 'sometimes|boolean', - 'imap_enabled' => 'sometimes|boolean', - 'quota' => 'sometimes|integer', - ]; - } - - public function withEventRules(): array - { - return [MailAccount::class, 'update']; - } -} diff --git a/packages/mail/src/Http/Requests/UpdateMailAddressRequest.php b/packages/mail/src/Http/Requests/UpdateMailAddressRequest.php deleted file mode 100644 index 02293db..0000000 --- a/packages/mail/src/Http/Requests/UpdateMailAddressRequest.php +++ /dev/null @@ -1,58 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'description' => 'nullable|string', - 'is_catchall' => 'sometimes|boolean', - function ($attribute, $value, $fail) { - if ($value) { - $exists = MailAddress::query()->where('domain_id', (int)$this->input('domain_id')) - ->where('is_catchall', true) - ->whereNot('id', $this->input('id')) // @todo is that right? - ->exists(); - - if ($exists) { - $fail('You have already defined a catchall address for this domain.'); - } - } - }, - 'rewrite_subject' => 'sometimes|boolean', - 'bypass_spam' => 'sometimes|boolean', - 'policy_greylist' => 'sometimes|boolean', - 'spam_tag_level' => 'sometimes|decimal:8,2', - 'spam_kill_level' => 'sometimes', 'decimal:8,2', - function ($attribute, $value, $fail) { - $tag = $this->input('spam_tag_level'); - if ($tag !== null && (float)$value <= (float)$tag) { - $fail('Spam kill level must be greater than the spam tag level.'); - } - }, - ]; - } - - public function withEventRules(): array - { - return [MailAddress::class, 'update']; - } -} diff --git a/packages/mail/src/Jobs/SendMailToNewAccountJob.php b/packages/mail/src/Jobs/SendMailToNewAccountJob.php deleted file mode 100644 index 6bd8b91..0000000 --- a/packages/mail/src/Jobs/SendMailToNewAccountJob.php +++ /dev/null @@ -1,20 +0,0 @@ -collection->collection->map(function ($resource) { - if ($resource->resource::class === \Froxlor\Domain\Models\Domain::class) { - /** - * add `has_mails` boolean property to each collection entry if it's a Domain model - */ - $domain = clone $resource->resource; - $resource->resource->has_mails = $domain->mail_addresses()->exists(); - if ($resource->resource->has_mails) { - $resource->resource->count_mails = $domain->mail_addresses()->count(); - } - } - }); - } -} diff --git a/packages/mail/src/Listeners/ExtendResourceResponse.php b/packages/mail/src/Listeners/ExtendResourceResponse.php deleted file mode 100644 index 29c4493..0000000 --- a/packages/mail/src/Listeners/ExtendResourceResponse.php +++ /dev/null @@ -1,22 +0,0 @@ -resource->resource instanceof \Froxlor\Domain\Models\Domain) { - /** - * add `has_mails` boolean property to each collection entry if it's a Domain model - */ - $domain = clone $event->resource->resource; - $event->resource->resource->has_mails = $domain->mail_addresses()->exists(); - if ($event->resource->resource->has_mails) { - $event->resource->resource->count_mails = $domain->mail_addresses()->count(); - } - } - } -} diff --git a/packages/mail/src/Listeners/SeedDatabase.php b/packages/mail/src/Listeners/SeedDatabase.php deleted file mode 100644 index 7d31d26..0000000 --- a/packages/mail/src/Listeners/SeedDatabase.php +++ /dev/null @@ -1,14 +0,0 @@ -databaseSeeder->call(DatabaseSeeder::class); - } -} diff --git a/packages/mail/src/Models/MailAccount.php b/packages/mail/src/Models/MailAccount.php deleted file mode 100644 index 8a9697e..0000000 --- a/packages/mail/src/Models/MailAccount.php +++ /dev/null @@ -1,52 +0,0 @@ - 'hashed', - ]; - } - - public function mailAddress(): BelongsTo - { - return $this->belongsTo(MailAddress::class); - } -} diff --git a/packages/mail/src/Models/MailAddress.php b/packages/mail/src/Models/MailAddress.php deleted file mode 100644 index 4c64f00..0000000 --- a/packages/mail/src/Models/MailAddress.php +++ /dev/null @@ -1,50 +0,0 @@ -belongsTo(Domain::class); - } - - public function mailAccount(): hasOne - { - return $this->hasOne(MailAccount::class); - } -} diff --git a/packages/mail/src/Providers/EventServiceProvider.php b/packages/mail/src/Providers/EventServiceProvider.php deleted file mode 100644 index b8909ef..0000000 --- a/packages/mail/src/Providers/EventServiceProvider.php +++ /dev/null @@ -1,27 +0,0 @@ -> - */ - protected $listen = [ - CoreEvents\DatabaseSeeded::class => [ - Listeners\SeedDatabase::class, - ], - CoreEvents\Api\ResourceResponseMade::class => [ - Listeners\ExtendResourceResponse::class, - ], - CoreEvents\Api\CollectionResponseMade::class => [ - Listeners\ExtendCollectionResponse::class, - ], - ]; -} diff --git a/packages/mail/src/Providers/FroxlorMailServiceProvider.php b/packages/mail/src/Providers/FroxlorMailServiceProvider.php deleted file mode 100644 index d013af4..0000000 --- a/packages/mail/src/Providers/FroxlorMailServiceProvider.php +++ /dev/null @@ -1,69 +0,0 @@ - [ - 'mail' => FroxlorVersion::installedApplicationVersion('froxlor/mail', FroxlorVersion::release()) - ]); - - // Migrations - $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - - // Routes - $this->loadRoutesFrom(__DIR__ . '/../../routes/api.php'); - $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); - - // Views - $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'froxlor-mail'); - - // Policies, Events etc. hier registrieren - - Relation::morphMap([ - 'mail_addresses' => Models\MailAddress::class, - ]); - - // Relations - $this->extendRelations(); - } - - public function register(): void - { - // - } - - private function extendRelations(): void - { - Domain::resolveRelationUsing('mail_addresses', function (Domain $domain) { - return $domain->hasMany(Models\MailAddress::class); - }); - - // ui view relations - $mailIndexSchema = MailSchema::indexSchema(); - Schemas\Schema::stack('tenants.domains.show.tabs', fn(Tenant $tenant, Domain $domain) => Schemas\Components\Tab::make('tenants.domains.show.tabs.domains') - ->label(trans('froxlor-mail::generic.mails')) - ->sort(5000) - ->components([ - Schemas\Components\Relation::make('tenants.domains.show.relations.mails') - ->fetch(route('api.tenants.domains.mail.index', $tenant)) - ->intendedRoute('tenants.domains.mail.show', ['tenant' => $tenant->id, 'domain' => $domain->id, 'mail' => '{id}']) - ->columns($mailIndexSchema['columns']) - ->actions($mailIndexSchema['actions']) - ]) - ); - } - -} diff --git a/packages/mail/src/Resources/Schemas/MailSchema.php b/packages/mail/src/Resources/Schemas/MailSchema.php deleted file mode 100644 index 75d197e..0000000 --- a/packages/mail/src/Resources/Schemas/MailSchema.php +++ /dev/null @@ -1,58 +0,0 @@ - [ - Tables\Columns\TextColumn::make('address') - ->label(trans('froxlor-mail::generic.address')) - ->sortable(), - - Tables\Columns\TextColumn::make('created_at') - ->label(trans('froxlor-core::generic.created_at')) - ->sortable(), - ], - 'actions' => [ - ] - ]; - } - - public static function showSchema(Domain $domain): array - { - return [ - 'columns' => [ - Schemas\Components\Section::make('main') - ->title(trans('froxlor-core::generic.title')) - ->components([ - Forms\Components\TextInput::make('name') - ->label(trans('froxlor-core::generic.name')) - ->required(), - - Forms\Components\TextInput::make('description') - ->label(trans('froxlor-core::generic.description')), - ]), - - Schemas\Components\Section::make('resources') - ->title(trans('froxlor-core::generic.resources')) - ->components([ - Forms\Components\Dump::make('demo') - ->default(fn() => $domain->domain_vhosts()->get()->toArray()), - ]), - ], - 'actions' => [ - Schemas\Actions\Action::make('back') - ->label(trans('froxlor-core::generic.back')) - ->href(route('resources.domains.index')), - ] - ]; - } -} diff --git a/packages/mail/src/Services/CreateAccount.php b/packages/mail/src/Services/CreateAccount.php deleted file mode 100644 index 807774b..0000000 --- a/packages/mail/src/Services/CreateAccount.php +++ /dev/null @@ -1,39 +0,0 @@ -validatedResource(); - // set address - $mailAccountData['mail_address_id'] = $mailAddress->id; - // create resource - $mailAccount = MailAccount::query()->create($mailAccountData); - // build up validated data for others - $eventData = $request->validatedEvent(); - // throw event that resource was created and append validated data - event(new CoreEvents\Api\ResourceCreated($mailAccount, $eventData)); - // add resource usage - Resource::addEnvironmentUsage($environment, $mailAccount); - - return $mailAccount->refresh(); - } -} diff --git a/packages/mail/src/Services/CreateMail.php b/packages/mail/src/Services/CreateMail.php deleted file mode 100644 index 08a0bc2..0000000 --- a/packages/mail/src/Services/CreateMail.php +++ /dev/null @@ -1,43 +0,0 @@ -validatedResource(); - // set domain - $mailAddressData['domain_id'] = $domain->id; - // create resource - $mailAddress = MailAddress::query()->create($mailAddressData); - // build up validated data for others - $eventData = $request->validatedEvent(); - // throw event that resource was created and append validated data - event(new CoreEvents\Api\ResourceCreated($mailAddress, $eventData)); - // add resource usage - Resource::addEnvironmentUsage($environment, $mailAddress); - - return $mailAddress->refresh(); - } -} diff --git a/packages/mail/src/Services/UpdateAccount.php b/packages/mail/src/Services/UpdateAccount.php deleted file mode 100644 index 63936f0..0000000 --- a/packages/mail/src/Services/UpdateAccount.php +++ /dev/null @@ -1,26 +0,0 @@ -validatedResource(); - // build up validated data for others - $eventData = $request->validatedEvent(); - // update resource - $mailAccount->update($mailAccountData); - // throw event that resource was updated - event(new CoreEvents\Api\ResourceUpdated($mailAccount, $eventData)); - - return $mailAccount->refresh(); - } -} diff --git a/packages/mail/src/Services/UpdateMail.php b/packages/mail/src/Services/UpdateMail.php deleted file mode 100644 index 71e3f55..0000000 --- a/packages/mail/src/Services/UpdateMail.php +++ /dev/null @@ -1,27 +0,0 @@ -validatedResource(); - // update resource - $mailAddress->update($mailAddressData); - // build up validated data for others - $eventData = $request->validatedEvent(); - // throw event that resource was updated - event(new CoreEvents\Api\ResourceUpdated($mailAddress, $eventData)); - - return $mailAddress->refresh(); - } -} diff --git a/packages/packages/database/seeders/DatabaseSeeder.php b/packages/packages/database/seeders/DatabaseSeeder.php index ecc7eea..2c9e831 100644 --- a/packages/packages/database/seeders/DatabaseSeeder.php +++ b/packages/packages/database/seeders/DatabaseSeeder.php @@ -18,12 +18,12 @@ public function run(): void { // call required package seeders $this->call($this->seederClasses()); - Audit::log('The package seeder classes have been seeded.'); + Audit::info('The package seeder classes have been seeded.'); // call development/test fixture seeders if (SeedProfile::includesDevelopmentData()) { $this->call($this->testingSeederClasses()); - Audit::log('The ' . SeedProfile::developmentDataLabel() . ' package seeder classes have been seeded.'); + Audit::debug('The ' . SeedProfile::developmentDataLabel() . ' package seeder classes have been seeded.'); } } diff --git a/packages/packages/src/Models/Repository.php b/packages/packages/src/Models/Repository.php index 521666b..1a75e54 100644 --- a/packages/packages/src/Models/Repository.php +++ b/packages/packages/src/Models/Repository.php @@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; /** diff --git a/packages/web/.gitignore b/packages/web/.gitignore deleted file mode 100644 index 8a33e12..0000000 --- a/packages/web/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules -/vendor -composer.lock -package-lock.json diff --git a/packages/web/composer.json b/packages/web/composer.json deleted file mode 100644 index b571b5c..0000000 --- a/packages/web/composer.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "https://getcomposer.org/schema.json", - "name": "froxlor/web", - "type": "library", - "description": "The froxlor web package.", - "keywords": [ - "froxlor", - "management", - "panel" - ], - "homepage": "https://www.froxlor.org", - "license": "proprietary", - "authors": [ - { - "name": "Michael Kaufmann", - "email": "d00p@froxlor.org", - "role": "Lead Developer" - }, - { - "name": "Maurice Preuß", - "email": "envoyr@froxlor.org", - "role": "Developer" - } - ], - "require": { - "php": "^8.5", - "froxlor/web": "*", - "illuminate/support": "^12.0" - }, - "autoload": { - "psr-4": { - "Froxlor\\Web\\": "src/", - "Froxlor\\Web\\Database\\Seeders\\": "database/seeders/" - } - }, - "extra": { - "froxlor": { - "type": "package" - }, - "laravel": { - "providers": [ - "Froxlor\\Web\\Providers\\EventServiceProvider", - "Froxlor\\Web\\Providers\\FroxlorWebServiceProvider" - ] - } - } -} diff --git a/packages/web/database/migrations/0001_01_04_000001_create_domain_vhost_table.php b/packages/web/database/migrations/0001_01_04_000001_create_domain_vhost_table.php deleted file mode 100644 index 73c2b59..0000000 --- a/packages/web/database/migrations/0001_01_04_000001_create_domain_vhost_table.php +++ /dev/null @@ -1,40 +0,0 @@ -ulid('id')->primary(); - $table->foreignUlid('domain_id')->constrained()->cascadeOnDelete(); - $table->foreignUlid('node_id')->constrained()->cascadeOnDelete(); - // web properties - $table->string('documentroot'); - $table->boolean('access_log')->default(true); - $table->boolean('error_log')->default(true); - $table->enum('alias_mode', ['none', 'www', 'wildcard'])->default('wildcard'); // formerly iswildcarddomain + wwwserveralias - // web custom-vhost properties - $table->boolean('notryfiles')->default(false); - $table->text('custom_vhost')->nullable(); -// $table->text('custom_ssl_vhost')->nullable(); -// $table->boolean('include_custom_vhost_in_ssl')->default(false); - - $table->timestamps(); - $table->softDeletes(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('domain_vhosts'); - } -}; diff --git a/packages/web/database/migrations/0001_01_04_000002_create_domain_ssl_vhost_table.php b/packages/web/database/migrations/0001_01_04_000002_create_domain_ssl_vhost_table.php deleted file mode 100644 index 6db0fa5..0000000 --- a/packages/web/database/migrations/0001_01_04_000002_create_domain_ssl_vhost_table.php +++ /dev/null @@ -1,45 +0,0 @@ -ulid('id')->primary(); - $table->foreignUlid('domain_vhost_id')->constrained()->cascadeOnDelete(); - // web-ssl properties - $table->boolean('ssl_redirect')->default(false); - $table->unsignedTinyInteger('ssl_mode')->default(0); // // 0 = off, 1 = auto, 2 = manual - $table->boolean('http2')->default(true); - $table->boolean('http3')->default(true); - $table->boolean('hsts_enabled')->default(false); - $table->unsignedTinyInteger('hsts_mode')->default(0); // bitwise 0 = none, 1 = sub, 2 = preload, 3 = sub + preload - $table->unsignedTinyInteger('hsts_maxage')->default(0); - $table->boolean('oscp_stapling')->default(false); - // protocols and ciphers - $table->boolean('override_tls')->default(false); - $table->string('ssl_protocols')->nullable(); - $table->string('ssl_cipher_list')->nullable(); - $table->string('tlsv13_cipher_list')->nullable(); - $table->boolean('ssl_honorcipherorder')->default(false); - $table->boolean('ssl_sessiontickets')->default(true); - - $table->timestamps(); - $table->softDeletes(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('domain_ssl_vhosts'); - } -}; diff --git a/packages/web/database/migrations/0001_01_04_000010_create_domain_vhosts_node_interfaces_table.php b/packages/web/database/migrations/0001_01_04_000010_create_domain_vhosts_node_interfaces_table.php deleted file mode 100644 index 1df840a..0000000 --- a/packages/web/database/migrations/0001_01_04_000010_create_domain_vhosts_node_interfaces_table.php +++ /dev/null @@ -1,27 +0,0 @@ -ulid('id')->primary(); - $table->foreignUlid('domain_vhost_id')->constrained()->cascadeOnDelete(); - $table->foreignUlid('node_interface_id')->constrained()->cascadeOnDelete(); - $table->unsignedInteger('port')->default(80); - $table->boolean('ssl_port')->default(false); - $table->timestamps(); - $table->softDeletes(); - - $table->unique(['domain_vhost_id', 'node_interface_id', 'port'], 'dvni_unique'); - }); - } - - public function down(): void - { - Schema::dropIfExists('domain_vhosts_node_interfaces'); - } -}; diff --git a/packages/web/database/seeders/DatabaseSeeder.php b/packages/web/database/seeders/DatabaseSeeder.php deleted file mode 100644 index ecca123..0000000 --- a/packages/web/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,51 +0,0 @@ -call($this->seederClasses()); - Audit::log('The web seeder classes have been seeded.'); - - // call development/test fixture seeders - if (SeedProfile::includesDevelopmentData()) { - $this->call($this->testingSeederClasses()); - Audit::log('The ' . SeedProfile::developmentDataLabel() . ' web seeder classes have been seeded.'); - } - } - - /** - * All essential seeders required for a minimal production installation. - * - * @return array> - */ - private function seederClasses(): array - { - return []; - } - - /** - * All non-production fixture seeders used by local development and tests. - * - * @return array> - */ - private function testingSeederClasses(): array - { - return [ - Testing\WebTableSeeder::class - ]; - } -} diff --git a/packages/web/database/seeders/Testing/WebTableSeeder.php b/packages/web/database/seeders/Testing/WebTableSeeder.php deleted file mode 100644 index 4f6d795..0000000 --- a/packages/web/database/seeders/Testing/WebTableSeeder.php +++ /dev/null @@ -1,70 +0,0 @@ - ['web.enabled' => true]]); - Setting::add('web.default_ssl_vhost_content', ''); - Setting::add('web.http2_enabled', true, true, 'boolean'); - Setting::add('web.http3_enabled', false, false, 'boolean'); - Setting::add('web.hsts_maxage', 10368000, 10368000, 'number', ['min' => 0, 'steps' => 1]); - Setting::add('web.ssl_protocols', 'TLSv1.2', 'TLSv1.2'); - Setting::add('web.ssl_cipher_list', 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305'); - - // node settings - Node::addTypeSetting('web.enabled', false, false, 'boolean'); - Node::addTypeSetting('web.httpd', 'apache', 'apache', 'select', ['options' => ['apache', 'nginx']]); - - /** - * @todo this is for debugging/development purposes - */ - $domain = Domain::query()->where('domain', 'example.dev')->first(); - $env = $domain->tenant->environments()->first(); - $node = $env->nodes()->first(); - $domain->update([ - 'environment_id' => $env->id, - 'node_id' => $node->id, - 'properties->web->enabled' => true, - ]); - - // node specific settings - $node->addSetting('web.enabled', true, true, 'boolean'); - - $domainVhost = DomainVhost::query()->create([ - 'domain_id' => $domain->id, - 'node_id' => $node->id, - 'documentroot' => $node->getSetting('node.basedir') . '/' . $domain->environment->id . '/home/wwwroot/' . $domain->domain, - 'alias_mode' => 'none' - ]); - - foreach ($node->nodeInterfaces as $interface) { - $domainVhost->nodeInterfaces()->attach( - [$interface->id], - ['port' => 80] - ); - $domainVhost->nodeInterfaces()->attach( - [$interface->id], - ['port' => 443, 'ssl_port' => true] - ); - } - } -} diff --git a/packages/web/resources/views/scripts/web-vhost/configure/apache/debian13.blade.php b/packages/web/resources/views/scripts/web-vhost/configure/apache/debian13.blade.php deleted file mode 100644 index 645572d..0000000 --- a/packages/web/resources/views/scripts/web-vhost/configure/apache/debian13.blade.php +++ /dev/null @@ -1 +0,0 @@ -@include('froxlor-web::templates.apache.domain_vhost') diff --git a/packages/web/resources/views/scripts/web-vhost/configure/apache/ubuntu2404.blade.php b/packages/web/resources/views/scripts/web-vhost/configure/apache/ubuntu2404.blade.php deleted file mode 100644 index 645572d..0000000 --- a/packages/web/resources/views/scripts/web-vhost/configure/apache/ubuntu2404.blade.php +++ /dev/null @@ -1 +0,0 @@ -@include('froxlor-web::templates.apache.domain_vhost') diff --git a/packages/web/resources/views/scripts/web-vhost/configure/nginx/debian13.blade.php b/packages/web/resources/views/scripts/web-vhost/configure/nginx/debian13.blade.php deleted file mode 100644 index f324cda..0000000 --- a/packages/web/resources/views/scripts/web-vhost/configure/nginx/debian13.blade.php +++ /dev/null @@ -1 +0,0 @@ -@include('froxlor-web::templates.nginx.domain_vhost') diff --git a/packages/web/resources/views/scripts/web-vhost/configure/nginx/ubuntu2404.blade.php b/packages/web/resources/views/scripts/web-vhost/configure/nginx/ubuntu2404.blade.php deleted file mode 100644 index f324cda..0000000 --- a/packages/web/resources/views/scripts/web-vhost/configure/nginx/ubuntu2404.blade.php +++ /dev/null @@ -1 +0,0 @@ -@include('froxlor-web::templates.nginx.domain_vhost') diff --git a/packages/web/resources/views/templates/apache/domain_vhost.blade.php b/packages/web/resources/views/templates/apache/domain_vhost.blade.php deleted file mode 100644 index a5ed2a0..0000000 --- a/packages/web/resources/views/templates/apache/domain_vhost.blade.php +++ /dev/null @@ -1,42 +0,0 @@ -@php use Froxlor\Web\Enums\SslMode; @endphp -@foreach (['http', 'https'] as $mode) - @if ($mode == 'https' && !$domainVhost->domainSslVhost()->exists()) - @continue - @endif - - - @foreach ($domainVhost->nodeInterfaces as $listenIf) - {{-- check for valid ports --}} - @if (empty($listenIf->bind_addr) or ($mode == 'http' && $listenIf->pivot->ssl_port == true) or ($mode == 'https' && $listenIf->pivot->ssl_port == false)) - @continue - @endif - {{-- create listen-statements --}} - bind_addr . ":" . $listenIf->pivot->port . " "; ?> - # Listen {{ $listenIf->bind_addr }}:{{ $listenIf->pivot->port }} - @endforeach - - {{-- create vhost container --}} - # {{ $mode }} vhost for domain {{ $domainVhost->domain->domain }} - - ServerName {{ $domainVhost->domain->domain }} - - @if ($mode == 'http' && $domainVhost->domainSslVhost()->exists() && $domainVhost->domainSslVhost->ssl_redirect) - - RewriteEngine On - RewriteCond %{HTTPS} off - @if ($domainVhost->domainSslVhost->ssl_mode == SslMode::Auto->value) - RewriteCond %{REQUEST_URI} !^/.well-known/acme-challenge/ - @endif - RewriteRule ^.*$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,QSA,L] - - - Redirect permanent / https://{{ $domainVhost->domain->domain }}/ - - @else - DocumentRoot "{{ $domainVhost->documentroot }}" - documentroot }}"> - # ... - - @endif - -@endforeach diff --git a/packages/web/resources/views/templates/nginx/domain_vhost.blade.php b/packages/web/resources/views/templates/nginx/domain_vhost.blade.php deleted file mode 100644 index 61d02a9..0000000 --- a/packages/web/resources/views/templates/nginx/domain_vhost.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -@php use Froxlor\Web\Enums\SslMode; @endphp -@foreach (['http', 'https'] as $mode) - @if ($mode == 'https' && !$domainVhost->domainSslVhost()->exists()) - @continue - @endif - -server { - @foreach ($domainVhost->nodeInterfaces as $listenIf) - @if (empty($listenIf->bind_addr) or ($mode == 'http' && $listenIf->pivot->ssl_port == true) or ($mode == 'https' && $listenIf->pivot->ssl_port == false)) - @continue - @endif - listen {{ $listenIf->bind_addr }}:{{ $listenIf->pivot->port }}; - @endforeach - - server_name {{ $domainVhost->domain->domain }}; - root {{ $domainVhost->documentroot }}; - - @if ($mode == 'http' && $domainVhost->domainSslVhost()->exists() && $domainVhost->domainSslVhost->ssl_redirect) - location / { - return 301 https://$host$request_uri; - } - @else - location / { - try_files $uri $uri/ /index.php?$query_string; - } - @endif -} -@endforeach diff --git a/packages/web/routes/api.php b/packages/web/routes/api.php deleted file mode 100644 index 41eb329..0000000 --- a/packages/web/routes/api.php +++ /dev/null @@ -1,5 +0,0 @@ -name('api.')->middleware(['api' /*, 'auth:sanctum' */])->group(function () { - -}); diff --git a/packages/web/routes/web.php b/packages/web/routes/web.php deleted file mode 100644 index 004d16b..0000000 --- a/packages/web/routes/web.php +++ /dev/null @@ -1,7 +0,0 @@ -group(function () { - -}); diff --git a/packages/web/src/Console/Commands/TestVhost.php b/packages/web/src/Console/Commands/TestVhost.php deleted file mode 100644 index 15d95aa..0000000 --- a/packages/web/src/Console/Commands/TestVhost.php +++ /dev/null @@ -1,43 +0,0 @@ -first(); - - if (!$dvhost->domainSslVhost()->exists()) { - $this->output->note('Creating domain-ssl-vhost'); - $dvhost->domainSslVhost()->create([ - 'ssl_redirect' => true, - 'ssl_mode' => SslMode::Auto - ]); - } - - (new GenerateDomainVhostJob($dvhost))->handle(); - } -} diff --git a/packages/web/src/Enums/HstsMode.php b/packages/web/src/Enums/HstsMode.php deleted file mode 100644 index c57f641..0000000 --- a/packages/web/src/Enums/HstsMode.php +++ /dev/null @@ -1,11 +0,0 @@ -|string> - */ - public function rules(): array - { - $rules = [ - 'is_http_domain' => 'required|boolean', - 'vhost' => 'required_if:is_http_domain,true|array', - 'vhost.documentroot' => 'required_with:vhost|string', - 'vhost.access_log' => 'sometimes|bool', - 'vhost.error_log' => 'sometimes|bool', - 'vhost.alias_mode' => ['sometimes', Rule::in(['none', 'www', 'wildcard'])], - 'vhost.notryfiles' => 'sometimes|bool', - 'vhost.custom_vhost' => 'sometimes|string', - ]; - if (Setting::get('web.ssl_enabled')) { - $rules = array_merge($rules, [ - 'is_https_domain' => 'required_if:is_http_domain,true|boolean', - 'ssl_vhost' => 'exclude_unless:is_http_domain,true|required_if:is_https_domain,true|array', - 'ssl_vhost.ssl_redirect' => 'nullable|bool', - 'ssl_vhost.ssl_mode' => ['nullable', Rule::enum(SslMode::class)], - 'ssl_vhost.http2' => 'nullable|bool', - 'ssl_vhost.http3' => 'nullable|bool', - 'ssl_vhost.hsts_enabled' => 'nullable|boolean', - 'ssl_vhost.hsts_mode' => ['exclude_unless:hsts_enabled,true', 'nullable', Rule::enum(HstsMode::class)], - 'ssl_vhost.hsts_maxage' => 'exclude_unless:hsts_enabled,true|nullable|numeric|min:0', - 'ssl_vhost.oscp_stapling' => 'nullable|boolean', - 'ssl_vhost.override_tls' => 'nullable|boolean', - 'ssl_vhost.ssl_protocols' => ['exclude_unless:override_tls,true', 'nullable', 'array', - function ($attribute, $value, $fail) { - foreach ($value as $item) { - if (!in_array($item, SslService::SSL_PROTOCOLS_AVAILABLE)) { - $fail("The $attribute contains an invalid value: $item. Must be one or more of " . implode(", ", SslService::SSL_PROTOCOLS_AVAILABLE)); - } - } - }], - ]); - } - return $rules; - } - - public function withEventRules(): array - { - return [DomainVhost::class, 'store']; - } -} diff --git a/packages/web/src/Jobs/GenerateDomainVhostJob.php b/packages/web/src/Jobs/GenerateDomainVhostJob.php deleted file mode 100644 index bd6869e..0000000 --- a/packages/web/src/Jobs/GenerateDomainVhostJob.php +++ /dev/null @@ -1,59 +0,0 @@ -domainVhost->loadMissing([ - 'domain.node', - 'nodeInterfaces', - 'domainSslVhost', - ]); - $node = $domainVhost->domain->node; - - if (! $node) { - throw new RuntimeException('Domain vhost has no assigned node.'); - } - - $http_flavor = $node->getSetting('web.httpd'); - $definition = ScriptRegistry::resolve('web-vhost', 'configure', $node, $http_flavor); - - if (! $definition) { - throw new RuntimeException(sprintf( - 'No web-vhost configure script registered for platform %s and variant %s.', - $node->platform()->key(), - $http_flavor - )); - } - - $context = [ - 'domainVhost' => $domainVhost, - 'node' => $node, - 'platform' => $node->platform(), - ]; - - $plan = app(ScriptDeployer::class)->plan($definition, $context); - app(ScriptDeployer::class)->apply($node, $plan); - } -} diff --git a/packages/web/src/Listeners/CreateDomain.php b/packages/web/src/Listeners/CreateDomain.php deleted file mode 100644 index 403fbfb..0000000 --- a/packages/web/src/Listeners/CreateDomain.php +++ /dev/null @@ -1,21 +0,0 @@ -model instanceof Domain) { - if ($event->validatedData['is_http_domain']) { - unset($event->validatedData['is_http_domain']); - $event->model->update(['properties->web->enabled' => true]); - app(DomainVhostService::class)->createDomainVhostForNewDomain($event->model, $event->validatedData); - } - } - } -} diff --git a/packages/web/src/Listeners/ExtendCollectionResponse.php b/packages/web/src/Listeners/ExtendCollectionResponse.php deleted file mode 100644 index e874f82..0000000 --- a/packages/web/src/Listeners/ExtendCollectionResponse.php +++ /dev/null @@ -1,23 +0,0 @@ -collection->collection->map(function ($resource) { - if ($resource->resource::class === \Froxlor\Domain\Models\Domain::class) { - /** - * add `has_vhost` boolean property to each collection entry if it's a Domain model - */ - $domain = clone $resource->resource; - $resource->resource->has_vhost = $domain->domain_vhost()->exists(); - } elseif ($resource->resource::class === \Froxlor\Core\Models\Node::class) { - // nothing yet - } - }); - } -} diff --git a/packages/web/src/Listeners/ExtendResourceResponse.php b/packages/web/src/Listeners/ExtendResourceResponse.php deleted file mode 100644 index 7ef4a75..0000000 --- a/packages/web/src/Listeners/ExtendResourceResponse.php +++ /dev/null @@ -1,26 +0,0 @@ -resource->resource instanceof \Froxlor\Domain\Models\Domain) { - /** - * add `domain_vhost` DomainVhost property to the Domain model - */ - $domain = $event->resource->resource; - $domain->load('domain_vhost', 'domain_vhost.domainSslVhost'); - } else if ($event->resource->resource instanceof \Froxlor\Core\Models\Node) { - /** - * add `domain_vhost` DomainVhost property to Node model - */ - // don't append all loaded relations, hence 'clone' - $node = clone $event->resource->resource; - $node->nodeInterfaces->load('domain_vhosts'); - } - } -} diff --git a/packages/web/src/Listeners/ExtendResourceValidation.php b/packages/web/src/Listeners/ExtendResourceValidation.php deleted file mode 100644 index 0abfb4c..0000000 --- a/packages/web/src/Listeners/ExtendResourceValidation.php +++ /dev/null @@ -1,24 +0,0 @@ -class === Domain::class) { - if (!Setting::get('web.enabled')) { - return; - } - // attach to all store-events - if (str_ends_with(strtolower($event->action), 'store')) { - $event->rules = array_merge($event->rules, (new StoreDomainVhostRequest())->rules()); - } - } - } -} diff --git a/packages/web/src/Listeners/SeedDatabase.php b/packages/web/src/Listeners/SeedDatabase.php deleted file mode 100644 index cafdd17..0000000 --- a/packages/web/src/Listeners/SeedDatabase.php +++ /dev/null @@ -1,14 +0,0 @@ -databaseSeeder->call(DatabaseSeeder::class); - } -} diff --git a/packages/web/src/Models/DomainSslVhost.php b/packages/web/src/Models/DomainSslVhost.php deleted file mode 100644 index 7fe7b1e..0000000 --- a/packages/web/src/Models/DomainSslVhost.php +++ /dev/null @@ -1,44 +0,0 @@ -belongsTo(DomainVhost::class); - } - -} diff --git a/packages/web/src/Models/DomainVhost.php b/packages/web/src/Models/DomainVhost.php deleted file mode 100644 index 499e21f..0000000 --- a/packages/web/src/Models/DomainVhost.php +++ /dev/null @@ -1,67 +0,0 @@ - $nodeInterfaces - * @property DomainSslVhost $domainSslVhost - */ -class DomainVhost extends Model -{ - use HasUlids, SoftDeletes; - - protected $guarded = []; - - public function domain(): BelongsTo - { - return $this->belongsTo(Domain::class); - } - - public function node(): BelongsTo - { - return $this->belongsTo(Node::class); - } - - public function nodeInterfaces(): BelongsToMany - { - return $this->belongsToMany( - NodeInterface::class, - 'domain_vhosts_node_interfaces', - 'domain_vhost_id', - 'node_interface_id' - )->withPivot(['port', 'ssl_port'])->using(DomainVhostsNodeInterfaces::class); - } - - public function domainSslVhost(): hasOne - { - return $this->hasOne(DomainSslVhost::class); - } -} diff --git a/packages/web/src/Models/DomainVhostsNodeInterfaces.php b/packages/web/src/Models/DomainVhostsNodeInterfaces.php deleted file mode 100644 index ddc76d0..0000000 --- a/packages/web/src/Models/DomainVhostsNodeInterfaces.php +++ /dev/null @@ -1,39 +0,0 @@ -belongsTo(DomainVhost::class); - } - - public function nodeInterface(): BelongsTo - { - return $this->belongsTo(NodeInterface::class); - } -} diff --git a/packages/web/src/Providers/EventServiceProvider.php b/packages/web/src/Providers/EventServiceProvider.php deleted file mode 100644 index 0ddf057..0000000 --- a/packages/web/src/Providers/EventServiceProvider.php +++ /dev/null @@ -1,34 +0,0 @@ -> - */ - protected $listen = [ - CoreEvents\DatabaseSeeded::class => [ - Listeners\SeedDatabase::class, - ], - CoreEvents\Api\ResourceResponseMade::class => [ - Listeners\ExtendResourceResponse::class, - ], - CoreEvents\Api\CollectionResponseMade::class => [ - Listeners\ExtendCollectionResponse::class, - ], - CoreEvents\Api\ResourceValidating::class => [ - Listeners\ExtendResourceValidation::class - ], - CoreEvents\Api\ResourceCreated::class => [ - Listeners\CreateDomain::class, - ], - ]; -} diff --git a/packages/web/src/Providers/FroxlorWebServiceProvider.php b/packages/web/src/Providers/FroxlorWebServiceProvider.php deleted file mode 100644 index 9204904..0000000 --- a/packages/web/src/Providers/FroxlorWebServiceProvider.php +++ /dev/null @@ -1,143 +0,0 @@ - [ - 'web' => FroxlorVersion::installedApplicationVersion('froxlor/web', FroxlorVersion::release()) - ]); - - // Migrations - $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - - // Routes - $this->loadRoutesFrom(__DIR__ . '/../../routes/api.php'); - $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); - - // Views - $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'froxlor-web'); - - // Policies, Events etc. hier registrieren - - Relation::morphMap([ - 'domain_vhosts' => Models\DomainVhost::class, - ]); - - // Relations - $this->extendRelations(); - - // Platform-specific provisioning scripts - $this->registerScripts(); - - // Cli commands - $this->loadCommandsFrom(__DIR__ . '/../Console'); - } - - public function register(): void - { - // - } - - private function extendRelations(): void - { - Domain::resolveRelationUsing('domain_vhost', function (Domain $domain) { - return $domain->hasOne(Models\DomainVhost::class); - }); - NodeInterface::resolveRelationUsing('domain_vhosts', function (NodeInterface $node) { - return $node->belongsToMany(Models\DomainVhost::class, - 'domain_vhosts_node_interfaces', - 'node_interface_id', - 'domain_vhost_id') - ->withPivot(['port', 'ssl_port']) - ->using(Models\DomainVhostsNodeInterfaces::class); - }); - } - - private function registerScripts(): void - { - ScriptRegistry::register(new ScriptDefinition( - feature: 'web-vhost', - action: 'configure', - platformKey: 'debian@13', - view: 'froxlor-web::scripts.web-vhost.configure.apache.debian13', - variant: 'apache', - targetPath: fn($domainVhost) => '/etc/apache2/sites-available/' . $domainVhost->domain->domain . '.conf', - runAsRoot: true, - ownership: ['root', 'root'], - reloadCommands: fn($domainVhost) => [ - 'apachectl configtest', - 'a2ensite ' . escapeshellarg($domainVhost->domain->domain . '.conf'), - 'systemctl reload apache2', - ], - package: 'web', - )); - - ScriptRegistry::register(new ScriptDefinition( - feature: 'web-vhost', - action: 'configure', - platformKey: 'debian@13', - view: 'froxlor-web::scripts.web-vhost.configure.nginx.debian13', - variant: 'nginx', - targetPath: fn($domainVhost) => '/etc/nginx/sites-available/' . $domainVhost->domain->domain . '.conf', - runAsRoot: true, - ownership: ['root', 'root'], - reloadCommands: fn($domainVhost) => [ - 'nginx -t', - 'ln -sf ' . escapeshellarg('/etc/nginx/sites-available/' . $domainVhost->domain->domain . '.conf') - . ' ' - . escapeshellarg('/etc/nginx/sites-enabled/' . $domainVhost->domain->domain . '.conf'), - 'systemctl reload nginx', - ], - package: 'web', - )); - - ScriptRegistry::register(new ScriptDefinition( - feature: 'web-vhost', - action: 'configure', - platformKey: 'ubuntu@24.04', - view: 'froxlor-web::scripts.web-vhost.configure.apache.ubuntu2404', - variant: 'apache', - targetPath: fn($domainVhost) => '/etc/apache2/sites-available/' . $domainVhost->domain->domain . '.conf', - runAsRoot: true, - ownership: ['root', 'root'], - reloadCommands: fn($domainVhost) => [ - 'apachectl configtest', - 'a2ensite ' . escapeshellarg($domainVhost->domain->domain . '.conf'), - 'systemctl reload apache2', - ], - package: 'web', - )); - - ScriptRegistry::register(new ScriptDefinition( - feature: 'web-vhost', - action: 'configure', - platformKey: 'ubuntu@24.04', - view: 'froxlor-web::scripts.web-vhost.configure.nginx.ubuntu2404', - variant: 'nginx', - targetPath: fn($domainVhost) => '/etc/nginx/sites-available/' . $domainVhost->domain->domain . '.conf', - runAsRoot: true, - ownership: ['root', 'root'], - reloadCommands: fn($domainVhost) => [ - 'nginx -t', - 'ln -sf ' . escapeshellarg('/etc/nginx/sites-available/' . $domainVhost->domain->domain . '.conf') - . ' ' - . escapeshellarg('/etc/nginx/sites-enabled/' . $domainVhost->domain->domain . '.conf'), - 'systemctl reload nginx', - ], - package: 'web', - )); - } -} diff --git a/packages/web/src/Services/DomainVhostService.php b/packages/web/src/Services/DomainVhostService.php deleted file mode 100644 index d04b53b..0000000 --- a/packages/web/src/Services/DomainVhostService.php +++ /dev/null @@ -1,39 +0,0 @@ -properties['web']['enabled']) { - // nothing to do for us - return; - } - // vhosts only make sense if domain is assigned to an environment (and thus to a node) - if ($domain->node()->exists()) { - // default Vhost - $http_data = $data['vhost']; - if (!empty($http_data)) { - $http_data['domain_id'] = $domain->id; - $http_data['node_id'] = $domain->node->id; - $domainVhost = DomainVhost::query()->create($http_data); - - // check for ssl vhost - if (Setting::get('web.ssl_enabled')) { - // ssl vhost - $https_data = $data['ss_vhost'] ?? []; - if (!empty($https_data)) { - $https_data['domain_vhost_id'] = $domainVhost->id; - DomainSslVhost::query()->create($https_data); - } - } - } - } - } -} diff --git a/packages/web/src/Services/SslService.php b/packages/web/src/Services/SslService.php deleted file mode 100644 index 68fcf90..0000000 --- a/packages/web/src/Services/SslService.php +++ /dev/null @@ -1,13 +0,0 @@ -