From 787c39515a9f3c82dcc88b961a2e57d8c857b005 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Sat, 20 Jun 2026 21:36:40 +0200 Subject: [PATCH 1/8] - Added `Froxlor\Core\Support\PlanAssignments` as the central plan-assignment guard. - Added `Froxlor\Core\Support\ResourceRegistry` as the central registration and synchronization point for Core and package-defined resources. - Added `IsTenantResource` and `IsEnvironmentResource` marker traits. Models keep using `IsResource` as the base technical contract and add one or both marker traits to opt into tenant/environment plan scopes. - Core and standard package model resources are now routed through `ResourceRegistry` before being synchronized by `PlansAndResourcesTableSeeder`. - Resource key conflicts are rejected per scope before the database unique key is hit. - Plan limits now follow the intended semantics consistently in assignment checks: `0` means no access, `-1` means unlimited, positive values are finite limits. - Tenant-user plan assignment now requires a tenant-scope plan that is global or owned by the route tenant. - Tenant-user explicit plans must be a subset of the tenant's own plan; they cannot grant missing resources, unlimited resources above a finite parent, or finite limits above the parent limit. - Environment-user plan assignment now requires an environment-scope plan that is global or owned by the route tenant. - Environment-user explicit plans must be a subset of the environment's plan. - Tenant/environment user creation paths now validate optional plan assignments through `PlanAssignments`, analogous to role assignment validation through `RoleAssignments`. - Plan delete operations now reject used plans with a validation response before deletion. This covers `tenants.plan_id`, `environments.plan_id`, `tenant_user.plan_id`, and `environment_user.plan_id`. - Added global plan-resource API routes for assigning/removing resources on global plans: - `GET /api/plans/{plan}/resources` - `POST /api/plans/{plan}/resources` - `DELETE /api/plans/{plan}/resources/{resource}` - Added tenant plan-resource API routes for assigning/removing resources on tenant-owned plans: - `GET /api/tenants/{tenant}/plans/{plan}/resources` - `POST /api/tenants/{tenant}/plans/{plan}/resources` - `DELETE /api/tenants/{tenant}/plans/{plan}/resources/{resource}` - Plan-resource listings return every resource matching the plan type with `assigned` and `limit` metadata for UI editing. - Plan-resource assignment validates that the resource type matches the plan type. - Tenant plan-resource assignment validates tenant-scope resource limits against the tenant's own plan. - Plan-resource attach/detach operations write audit log entries and dispatch `ResourceUpdated` for the plan. - Added `plans.resources.*` and `tenants.plans.resources.*` permission keys. - Added PHPUnit coverage for plan assignment scope/type checks, subset-limit checks, unlimited-above-finite denial, assigned-plan delete guards, plan-resource assignment, response metadata, type mismatch denial, parent-limit denial, foreign/global route isolation, unassigned detach validation, and audit logging. - Added PHPUnit coverage for the resource registry contract, duplicate key detection per scope, and automatic package resource seeding. - Core, domain, and mail testing seeders now attach package resources to the new deterministic test plans instead of relying on the old generic `Unlimited` plan name. Signed-off-by: Michael Kaufmann --- docker-compose.ci.yml | 1 + .../seeders/PlansAndResourcesTableSeeder.php | 184 ++++++++---- .../seeders/Testing/DatabaseSeeder.php | 1 + .../Testing/PlansAndResourcesTableSeeder.php | 84 ++++++ .../TenantAndEnvironmentsTableSeeder.php | 5 +- .../Testing/TenantAndUsersTableSeeder.php | 6 +- .../Testing/TenantUsagesTableSeeder.php | 2 +- packages/core/routes/api.php | 2 + .../Api/Plan/PlanResourceController.php | 107 +++++++ .../Http/Controllers/Api/PlanController.php | 2 + .../Api/Tenant/Environment/UserController.php | 3 + .../Tenant/Plan/PlanResourceController.php | 108 +++++++ .../Controllers/Api/Tenant/PlanController.php | 2 + .../Controllers/Api/Tenant/UserController.php | 20 +- packages/core/src/Models/Environment.php | 3 +- packages/core/src/Models/Node.php | 3 +- packages/core/src/Models/Plan.php | 31 +- packages/core/src/Models/Resource.php | 2 +- packages/core/src/Models/Role.php | 3 +- packages/core/src/Models/Tenant.php | 7 +- packages/core/src/Models/User.php | 4 +- packages/core/src/Policies/PlanPolicy.php | 45 +++ .../Providers/FroxlorCoreServiceProvider.php | 2 + .../Services/Bootstrap/BootstrapService.php | 5 +- .../Services/Traits/IsEnvironmentResource.php | 11 + .../src/Services/Traits/IsTenantResource.php | 11 + packages/core/src/Support/PlanAssignments.php | 190 ++++++++++++ .../core/src/Support/ResourceRegistry.php | 274 ++++++++++++++++++ .../Feature/EnvironmentResourceUsageTest.php | 8 +- .../tests/Feature/NodeResourceUsageTest.php | 10 +- .../tests/Feature/PlanAuthorizationTest.php | 18 ++ .../Feature/PlanResourceAuthorizationTest.php | 128 ++++++++ .../tests/Feature/ResourceRegistryTest.php | 97 +++++++ ...TenantEnvironmentUserAuthorizationTest.php | 83 ++++++ .../Feature/TenantNodeAuthorizationTest.php | 2 +- .../Feature/TenantPlanAuthorizationTest.php | 21 ++ .../TenantPlanResourceAuthorizationTest.php | 143 +++++++++ .../Feature/TenantUserAuthorizationTest.php | 93 ++++++ packages/database/src/Models/Database.php | 3 +- .../seeders/Testing/DomainTableSeeder.php | 18 +- packages/domain/src/Models/Domain.php | 3 +- packages/ftp/routes/api.php | 1 + .../seeders/Testing/MailTableSeeder.php | 27 +- packages/mail/src/Models/MailAccount.php | 3 +- packages/mail/src/Models/MailAddress.php | 3 +- 45 files changed, 1658 insertions(+), 121 deletions(-) create mode 100644 packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php create mode 100644 packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php create mode 100644 packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php create mode 100644 packages/core/src/Services/Traits/IsEnvironmentResource.php create mode 100644 packages/core/src/Services/Traits/IsTenantResource.php create mode 100644 packages/core/src/Support/PlanAssignments.php create mode 100644 packages/core/src/Support/ResourceRegistry.php create mode 100644 packages/core/tests/Feature/PlanResourceAuthorizationTest.php create mode 100644 packages/core/tests/Feature/ResourceRegistryTest.php create mode 100644 packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php 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/database/seeders/PlansAndResourcesTableSeeder.php b/packages/core/database/seeders/PlansAndResourcesTableSeeder.php index f6aaf5d..fcf3540 100644 --- a/packages/core/database/seeders/PlansAndResourcesTableSeeder.php +++ b/packages/core/database/seeders/PlansAndResourcesTableSeeder.php @@ -9,79 +9,165 @@ 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 + * Tenant plans only contain tenant-scope resources. Environment plans only contain + * environment-scope resources. 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(); + + self::createTenantPlan('Platform Unlimited', [ + 'tenants' => -1, + 'environments' => -1, + 'nodes' => -1, + 'plans' => -1, + 'users' => -1, + 'roles' => -1, + ]); + + self::createTenantPlan('Tenant Standard', [ + 'tenants' => 0, + 'environments' => 10, + 'nodes' => 0, + 'plans' => 5, + 'users' => 25, + 'roles' => 10, + ]); + + self::createTenantPlan('Tenant Starter', [ + 'tenants' => 0, + 'environments' => 1, + 'nodes' => 0, + 'plans' => 0, + 'users' => 3, + 'roles' => 3, + ]); + + 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 tenant-scope plan. + * + * @param array $limits Resource key to limit map. + */ + public static function createTenantPlan(string $name, array $limits, ?string $tenantId = null): Plan + { + return self::createPlanWithResourceLimits($name, 'tenant', $limits, $tenantId); + } + + /** + * Create or update a global or tenant-owned environment-scope plan. + * + * @param array $limits Resource key to limit map. + */ + public static function createEnvironmentPlan(string $name, array $limits, ?string $tenantId = null): Plan + { + return self::createPlanWithResourceLimits($name, 'environment', $limits, $tenantId); + } + + /** + * Create a plan and attach resource limits matching the plan scope. * - * @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. + * @param array $limits Resource key to limit map. */ - public static function createPlanWithResources(string $string, array $resources, ?string $tenant_id = null): Plan + private static function createPlanWithResourceLimits(string $name, string $type, array $limits, ?string $tenantId = null): Plan { - /** @var Plan $role */ - $plan = Plan::query()->create([ - 'name' => $string, - 'tenant_id' => !empty($tenant_id) ? $tenant_id : null, + /** @var Plan $plan */ + $plan = Plan::query()->updateOrCreate([ + 'tenant_id' => $tenantId, + 'type' => $type, + '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'] + $resources = $type === 'tenant' ? self::tenantResources() : self::environmentResources(); + + foreach ($limits as $key => $limit) { + if (!isset($resources[$key])) { + throw new \InvalidArgumentException('Unknown ' . $type . ' resource key "' . $key . '" for 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/Testing/DatabaseSeeder.php b/packages/core/database/seeders/Testing/DatabaseSeeder.php index 881206c..3b71be2 100644 --- a/packages/core/database/seeders/Testing/DatabaseSeeder.php +++ b/packages/core/database/seeders/Testing/DatabaseSeeder.php @@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $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..216d84c --- /dev/null +++ b/packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php @@ -0,0 +1,84 @@ + -1, + 'environments' => -1, + 'nodes' => -1, + 'plans' => -1, + 'users' => -1, + 'roles' => -1, + ]); + + BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Limited', [ + 'tenants' => 2, + 'environments' => 2, + 'nodes' => 2, + 'plans' => 2, + 'users' => 2, + 'roles' => 2, + ]); + + BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Minimal', [ + 'tenants' => 0, + 'environments' => 1, + 'nodes' => 0, + 'plans' => 0, + 'users' => 1, + 'roles' => 1, + ]); + + BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Delegation Parent', [ + 'tenants' => 0, + 'environments' => 5, + 'nodes' => 1, + 'plans' => 3, + 'users' => 5, + 'roles' => 5, + ]); + + BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Delegation Child', [ + 'tenants' => 0, + 'environments' => 2, + 'nodes' => 0, + 'plans' => 1, + 'users' => 2, + 'roles' => 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..b4815b7 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,7 +92,7 @@ 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..11dc9f3 100644 --- a/packages/core/routes/api.php +++ b/packages/core/routes/api.php @@ -26,10 +26,12 @@ 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::apiResource('plans', Api\PlanController::class); + Route::apiResource('plans.resources', Api\Plan\PlanResourceController::class)->only(['index', 'store', 'destroy']); Route::apiResource('roles/permissions', Api\PermissionController::class)->only(['index'])->names([ 'index' => 'roles.permissions.available', ]); 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..64c93a1 --- /dev/null +++ b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php @@ -0,0 +1,107 @@ +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() + ->where('type', $plan->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::ensureResourceCanBeAttached($plan, $resource, (int)$data['limit']); + + $plan->resources()->syncWithoutDetaching([ + $resource->id => ['limit' => (int)$data['limit']], + ]); + + Audit::log('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.', + ]); + } + + $plan->resources()->detach($resource); + + Audit::log('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/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/Tenant/Environment/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php index 11840f7..94c5351 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php @@ -12,6 +12,7 @@ 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); 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..984592d --- /dev/null +++ b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php @@ -0,0 +1,108 @@ +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() + ->where('type', $plan->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::ensureResourceCanBeAttached($plan, $resource, (int)$data['limit'], $tenant); + + $plan->resources()->syncWithoutDetaching([ + $resource->id => ['limit' => (int)$data['limit']], + ]); + + Audit::log('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.', + ]); + } + + $plan->resources()->detach($resource); + + Audit::log('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..1aceb08 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; @@ -95,6 +96,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/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php index 8c9cada..3459668 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); @@ -110,7 +110,7 @@ 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); + PlanAssignments::ensureAssignableToTenantUser($planId, $tenant); $user->update($userData); @@ -145,18 +145,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/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/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..54c8bef 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; @@ -28,7 +29,9 @@ */ class Plan extends Model { - use HasUlids, IsResource, HasPermissions; + use HasUlids, IsResource, IsTenantResource, HasPermissions { + HasPermissions::getAllPermissions as protected getBasePermissions; + } protected $guarded = []; @@ -49,6 +52,24 @@ public function resources(): BelongsToMany ->using(PlanResource::class); } + /** + * 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 static function getAllPermissions(): array + { + 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'], + ]; + } + /** * Limit the query to plans usable for environments. */ @@ -71,6 +92,14 @@ public function scopeAvailableForTenant(Builder $query, Tenant $tenant): Builder }); } + /** + * Check whether this plan can be assigned to tenant users. + */ + public function isTenantPlan(): bool + { + return $this->type === 'tenant'; + } + /** * Check whether this plan can be assigned to an environment. */ 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..dbae56d 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; @@ -36,7 +37,7 @@ */ class Tenant extends Model { - use HasUlids, IsResource, HasPermissions; + use HasUlids, IsResource, IsTenantResource, HasPermissions; public $guarded = []; @@ -308,6 +309,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/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/Policies/PlanPolicy.php b/packages/core/src/Policies/PlanPolicy.php index 6211d67..7e45327 100644 --- a/packages/core/src/Policies/PlanPolicy.php +++ b/packages/core/src/Policies/PlanPolicy.php @@ -49,6 +49,24 @@ 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 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 tenantViewAny(User $user, Tenant $tenant): bool { return $this->hasScopedPermission($user, 'tenants.plans.index', $tenant); @@ -86,6 +104,33 @@ public function tenantDelete(User $user, Plan $plan, Tenant $tenant): bool return $this->hasScopedPermission($user, 'tenants.plans.destroy', $tenant); } + public function tenantResourceViewAny(User $user, Plan $plan, Tenant $tenant): bool + { + if ($plan->tenant_id !== $tenant->id) { + return false; + } + + return $this->hasScopedPermission($user, 'tenants.plans.resources.index', $tenant); + } + + public function tenantResourceCreate(User $user, Plan $plan, Tenant $tenant): bool + { + if ($plan->tenant_id !== $tenant->id) { + return false; + } + + return $this->hasScopedPermission($user, 'tenants.plans.resources.store', $tenant); + } + + public function tenantResourceDelete(User $user, Plan $plan, Tenant $tenant): bool + { + if ($plan->tenant_id !== $tenant->id) { + return false; + } + + return $this->hasScopedPermission($user, 'tenants.plans.resources.destroy', $tenant); + } + public function tenantEnvViewAny(User $user, Tenant $tenant, Environment $environment): bool { return $this->hasScopedPermission($user, 'tenants.environments.plans.index', $tenant, $environment); 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/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 @@ +with('resources')->findOrFail($planId); + + if (!$plan->isTenantPlan() || !$plan->isAvailableForTenant($tenant)) { + throw self::validationException($field, 'The selected plan is not available for this tenant.'); + } + + self::ensureWithinParentPlan($plan, $tenant->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', + ): void { + if (empty($planId)) { + return; + } + + $plan = Plan::query()->with('resources')->findOrFail($planId); + + if (!$plan->isEnvironmentPlan() || !$plan->isAvailableForTenant($tenant)) { + throw self::validationException($field, 'The selected plan is not available for this environment.'); + } + + self::ensureWithinParentPlan($plan, $environment->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(), + ]; + + $usedBy = collect($assignments) + ->filter() + ->keys() + ->implode(', '); + + if ($usedBy !== '') { + throw self::validationException('plan', 'The plan is still assigned to ' . $usedBy . '.'); + } + } + + /** + * Ensure a resource can be attached to the given plan with the requested limit. + * + * Plan resources must match the plan scope. For tenant-owned tenant plans, the + * resource limit must also stay within the owning tenant's assigned plan so tenants + * cannot create child plans that grant more tenant-scope 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 ($resource->type !== $plan->type) { + throw self::validationException($field, 'The selected resource is not available for this plan type.'); + } + + if ($tenant === null || !$plan->isTenantPlan() || $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) + ->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) => [$resource->key => (int)$resource->pivot->limit]); + + foreach ($childPlan->resources as $childResource) { + $childLimit = (int)$childResource->pivot->limit; + + if ($childLimit === 0) { + continue; + } + + $parentLimit = $parentResources->get($childResource->key); + + 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.'); + } + } + } + + private static function validationException(string $field, string $message): ValidationException + { + return ValidationException::withMessages([ + $field => $message, + ]); + } +} 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/tests/Feature/EnvironmentResourceUsageTest.php b/packages/core/tests/Feature/EnvironmentResourceUsageTest.php index 3c2eec2..29abef7 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', [ @@ -68,8 +68,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 +97,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..4603ea0 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'); @@ -84,8 +84,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 +109,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 +146,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/PlanAuthorizationTest.php b/packages/core/tests/Feature/PlanAuthorizationTest.php index 2ffbc32..c2b2ef2 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; @@ -56,6 +57,23 @@ 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_tenant_admin_cannot_manage_global_plan(): void { $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); diff --git a/packages/core/tests/Feature/PlanResourceAuthorizationTest.php b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php new file mode 100644 index 0000000..56a0af8 --- /dev/null +++ b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php @@ -0,0 +1,128 @@ +where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('type', 'tenant')->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('type', 'tenant')->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, + 'type' => 'tenant', + '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_plan_resource_type_must_match_plan_type(): void + { + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $plan = Plan::query()->whereNull('tenant_id')->where('type', 'tenant')->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, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['resource_id']); + } + + 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('type', 'tenant')->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']); + } +} diff --git a/packages/core/tests/Feature/ResourceRegistryTest.php b/packages/core/tests/Feature/ResourceRegistryTest.php new file mode 100644 index 0000000..76a646e --- /dev/null +++ b/packages/core/tests/Feature/ResourceRegistryTest.php @@ -0,0 +1,97 @@ +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' => 'domains', + 'type' => 'environment', + 'model_type' => Domain::class, + ]); + + $this->assertDatabaseHas('resources', [ + 'key' => 'mailaddresses', + 'type' => 'environment', + 'model_type' => MailAddress::class, + ]); + + $this->assertTrue(Resource::query() + ->where('key', 'users') + ->where('type', 'tenant') + ->where('model_type', User::class) + ->exists()); + $this->assertTrue(Resource::query() + ->where('key', 'users') + ->where('type', 'environment') + ->where('model_type', User::class) + ->exists()); + } +} diff --git a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php index ab0ddd0..e095140 100644 --- a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php @@ -3,6 +3,8 @@ 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; @@ -118,4 +120,85 @@ public function test_environment_admin_cannot_assign_tenant_role_without_delegat ->assertUnprocessable() ->assertJsonValidationErrors(['tenant_role']); } + + public function test_environment_user_plan_must_be_environment_scope_and_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, + 'type' => 'environment', + '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, + 'type' => 'environment', + 'name' => 'Environment Child User Plan ' . str()->ulid(), + ]); + $validPlan->resources()->attach($resource, ['limit' => 1]); + + $tooLargePlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'type' => 'environment', + 'name' => 'Environment Too Large User Plan ' . str()->ulid(), + ]); + $tooLargePlan->resources()->attach($resource, ['limit' => 3]); + + $wrongTypePlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'type' => 'tenant', + 'name' => 'Wrong Environment User Plan Type ' . str()->ulid(), + ]); + $wrongTypePlan->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' => 'Wrong Environment Plan User', + 'email' => 'environment-wrong-plan-user-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + 'tenant_role' => $tenantRole->id, + 'environment_role' => $environmentRole->id, + 'environment_plan' => $wrongTypePlan->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['environment_plan']); + } } diff --git a/packages/core/tests/Feature/TenantNodeAuthorizationTest.php b/packages/core/tests/Feature/TenantNodeAuthorizationTest.php index 0921749..d4e8ff2 100644 --- a/packages/core/tests/Feature/TenantNodeAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantNodeAuthorizationTest.php @@ -101,7 +101,7 @@ 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()->where('name', 'Test Tenant Limited')->firstOrFail(); $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..3171abc 100644 --- a/packages/core/tests/Feature/TenantPlanAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantPlanAuthorizationTest.php @@ -42,6 +42,27 @@ 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(), + 'type' => 'tenant', + ]); + $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(); diff --git a/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php b/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php new file mode 100644 index 0000000..a513b98 --- /dev/null +++ b/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php @@ -0,0 +1,143 @@ +where('name', 'First customer')->firstOrFail(); + $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'type' => 'tenant', + '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, + 'type' => 'tenant', + '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, + 'type' => 'tenant', + 'name' => 'Foreign Tenant Resource Plan ' . str()->ulid(), + ]); + $globalPlan = Plan::query()->whereNull('tenant_id')->where('type', 'tenant')->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, + 'type' => 'tenant', + '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, + 'type' => 'tenant', + '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']); + } +} diff --git a/packages/core/tests/Feature/TenantUserAuthorizationTest.php b/packages/core/tests/Feature/TenantUserAuthorizationTest.php index 9d3c400..5147a95 100644 --- a/packages/core/tests/Feature/TenantUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantUserAuthorizationTest.php @@ -2,6 +2,8 @@ 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; @@ -65,6 +67,97 @@ public function test_tenant_admin_can_assign_tenant_owned_role_to_tenant_user(): ->assertCreated(); } + public function test_tenant_user_plan_must_be_tenant_scope_and_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, + 'type' => 'tenant', + '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, + 'type' => 'tenant', + 'name' => 'Tenant Child User Plan ' . str()->ulid(), + ]); + $validPlan->resources()->attach($resource, ['limit' => 1]); + + $tooLargePlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'type' => 'tenant', + 'name' => 'Tenant Too Large User Plan ' . str()->ulid(), + ]); + $tooLargePlan->resources()->attach($resource, ['limit' => 3]); + + $unlimitedPlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'type' => 'tenant', + 'name' => 'Tenant Unlimited User Plan ' . str()->ulid(), + ]); + $unlimitedPlan->resources()->attach($resource, ['limit' => -1]); + + $wrongTypePlan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'type' => 'environment', + 'name' => 'Wrong Tenant User Plan Type ' . str()->ulid(), + ]); + $wrongTypePlan->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' => $wrongTypePlan->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/database/src/Models/Database.php b/packages/database/src/Models/Database.php index d5cf6cf..d8380db 100644 --- a/packages/database/src/Models/Database.php +++ b/packages/database/src/Models/Database.php @@ -3,6 +3,7 @@ namespace Froxlor\Database\Models; use Froxlor\Core\Models\Environment; +use Froxlor\Core\Services\Traits\IsEnvironmentResource; use Froxlor\Core\Services\Traits\IsResource; use Froxlor\Database\Observers\DatabaseObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -35,7 +36,7 @@ #[ObservedBy(DatabaseObserver::class)] class Database extends Model { - use HasUlids, IsResource, SoftDeletes; + use HasUlids, IsResource, IsEnvironmentResource, SoftDeletes; protected $guarded = []; diff --git a/packages/domain/database/seeders/Testing/DomainTableSeeder.php b/packages/domain/database/seeders/Testing/DomainTableSeeder.php index ec40ee5..d7f4731 100644 --- a/packages/domain/database/seeders/Testing/DomainTableSeeder.php +++ b/packages/domain/database/seeders/Testing/DomainTableSeeder.php @@ -18,19 +18,19 @@ class DomainTableSeeder extends Seeder */ public function run(): void { - // add ourselves as available resource - $domain_resource = Resource::query()->create([ + $domainResource = Resource::query()->where([ 'key' => 'domains', - 'name' => 'Domains', 'type' => 'environment', 'model_type' => Domain::class, - ]); + ])->firstOrFail(); - // add to unlimited plan to be available for super-admin - $plan = Plan::query()->where('name', 'Unlimited')->first(); - $plan->resources()->attach($domain_resource, [ - 'limit' => -1 - ]); + // add to environment plans to be available in environment-scoped tests + foreach (['Environment Unlimited', 'Test Environment Unlimited'] as $planName) { + $plan = Plan::query()->where('name', $planName)->firstOrFail(); + $plan->resources()->syncWithoutDetaching([ + $domainResource->id => ['limit' => -1], + ]); + } /** * @todo this is for debugging/development purposes diff --git a/packages/domain/src/Models/Domain.php b/packages/domain/src/Models/Domain.php index 5f941a4..fa4c859 100644 --- a/packages/domain/src/Models/Domain.php +++ b/packages/domain/src/Models/Domain.php @@ -6,6 +6,7 @@ use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Services\Traits\HasPermissions; +use Froxlor\Core\Services\Traits\IsEnvironmentResource; use Froxlor\Core\Services\Traits\IsResource; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; @@ -29,7 +30,7 @@ */ class Domain extends Model { - use HasUlids, HasPermissions, IsResource; + use HasUlids, HasPermissions, IsResource, IsEnvironmentResource; protected $guarded = []; diff --git a/packages/ftp/routes/api.php b/packages/ftp/routes/api.php index 78b8c23..71fcf0c 100644 --- a/packages/ftp/routes/api.php +++ b/packages/ftp/routes/api.php @@ -1,5 +1,6 @@ prefix('api')->name('api.')->group(function () { diff --git a/packages/mail/database/seeders/Testing/MailTableSeeder.php b/packages/mail/database/seeders/Testing/MailTableSeeder.php index fa34230..495b05e 100644 --- a/packages/mail/database/seeders/Testing/MailTableSeeder.php +++ b/packages/mail/database/seeders/Testing/MailTableSeeder.php @@ -21,28 +21,25 @@ class MailTableSeeder extends Seeder */ public function run(): void { - // add ourselves as available resource - $mailAddrResource = Resource::query()->create([ + $mailAddrResource = Resource::query()->where([ 'key' => 'mailaddresses', - 'name' => 'Mail addresses', 'type' => 'environment', 'model_type' => MailAddress::class, - ]); - $mailAccResource = Resource::query()->create([ + ])->firstOrFail(); + $mailAccResource = Resource::query()->where([ 'key' => 'mailaccounts', - 'name' => 'Mail accounts', 'type' => 'environment', 'model_type' => MailAccount::class, - ]); + ])->firstOrFail(); - // 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 - ]); + // add to environment plans to be available in environment-scoped tests + foreach (['Environment Unlimited', 'Test Environment Unlimited'] as $planName) { + $plan = Plan::query()->where('name', $planName)->firstOrFail(); + $plan->resources()->syncWithoutDetaching([ + $mailAddrResource->id => ['limit' => -1], + $mailAccResource->id => ['limit' => -1], + ]); + } // introduce our settings Setting::add('mail.enabled', true, true, 'boolean'); diff --git a/packages/mail/src/Models/MailAccount.php b/packages/mail/src/Models/MailAccount.php index 8a9697e..5e3b4c4 100644 --- a/packages/mail/src/Models/MailAccount.php +++ b/packages/mail/src/Models/MailAccount.php @@ -2,6 +2,7 @@ namespace Froxlor\Mail\Models; +use Froxlor\Core\Services\Traits\IsEnvironmentResource; use Froxlor\Core\Services\Traits\IsResource; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; @@ -30,7 +31,7 @@ */ class MailAccount extends Model { - use HasUlids, SoftDeletes, IsResource; + use HasUlids, SoftDeletes, IsResource, IsEnvironmentResource; protected $guarded = []; diff --git a/packages/mail/src/Models/MailAddress.php b/packages/mail/src/Models/MailAddress.php index 4c64f00..eb470b2 100644 --- a/packages/mail/src/Models/MailAddress.php +++ b/packages/mail/src/Models/MailAddress.php @@ -2,6 +2,7 @@ namespace Froxlor\Mail\Models; +use Froxlor\Core\Services\Traits\IsEnvironmentResource; use Froxlor\Core\Services\Traits\IsResource; use Froxlor\Domain\Models\Domain; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -30,7 +31,7 @@ */ class MailAddress extends Model { - use HasUlids, SoftDeletes, IsResource; + use HasUlids, SoftDeletes, IsResource, IsEnvironmentResource; protected $guarded = []; From e27b1f297cca1c6927219f123ba9b58509f27731 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Sun, 21 Jun 2026 00:51:08 +0200 Subject: [PATCH 2/8] add missing dependency on phpseclib Signed-off-by: Michael Kaufmann --- .github/workflows/run-unit-tests.yml | 2 ++ composer.json | 3 ++- packages/core/composer.json | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) 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..03168d6 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": { 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": { From 821e941ab0e4a24ed44ab8838d6dbe907a09c299 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Sun, 21 Jun 2026 10:18:01 +0200 Subject: [PATCH 3/8] - GET /api/plans/resources lists available resources in read-only mode. - Resource CRUD operations via /api/resources have been removed. - GET /api/plans/{plan}/users lists plan usage based on tenant, environment, TenantUser, and EnvironmentUser assignments. - Deleting an environment plan now also utilizes the central PlanAssignments guard. - Plan updates now block the `tenant_id` field and prevent changes to the `type` once resources or assignments exist. Signed-off-by: Michael Kaufmann --- packages/core/routes/api.php | 5 +- .../Controllers/Api/Plan/UserController.php | 140 ++++++++++++++++++ .../Controllers/Api/ResourceController.php | 47 +----- .../Tenant/Environment/PlansController.php | 2 + .../src/Http/Requests/UpdatePlanRequest.php | 41 ++++- packages/core/src/Models/Plan.php | 2 + packages/core/src/Policies/PlanPolicy.php | 11 ++ .../tests/Feature/PlanAuthorizationTest.php | 62 ++++++++ .../tests/Feature/ResourceValidationTest.php | 63 +++----- ...TenantEnvironmentPlanAuthorizationTest.php | 25 ++++ 10 files changed, 311 insertions(+), 87 deletions(-) create mode 100644 packages/core/src/Http/Controllers/Api/Plan/UserController.php diff --git a/packages/core/routes/api.php b/packages/core/routes/api.php index 11dc9f3..93c2787 100644 --- a/packages/core/routes/api.php +++ b/packages/core/routes/api.php @@ -30,17 +30,16 @@ 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/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/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/Tenant/Environment/PlansController.php b/packages/core/src/Http/Controllers/Api/Tenant/Environment/PlansController.php index 375963c..349da60 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/PlansController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Environment/PlansController.php @@ -11,6 +11,7 @@ use Froxlor\Core\Models\Environment; 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; @@ -79,6 +80,7 @@ public function update(UpdatePlanRequest $request, Tenant $tenant, Environment $ public function destroy(Request $request, Tenant $tenant, Environment $environment, Plan $plan) { Gate::authorize('tenantEnvDelete', [$plan, $tenant, $environment]); + PlanAssignments::ensureNotAssigned($plan); $plan->delete(); event(new ResourceDeleted($plan, [])); diff --git a/packages/core/src/Http/Requests/UpdatePlanRequest.php b/packages/core/src/Http/Requests/UpdatePlanRequest.php index 8264fe3..2bc620e 100644 --- a/packages/core/src/Http/Requests/UpdatePlanRequest.php +++ b/packages/core/src/Http/Requests/UpdatePlanRequest.php @@ -2,7 +2,10 @@ namespace Froxlor\Core\Http\Requests; +use Froxlor\Core\Models\Plan; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Validator; class UpdatePlanRequest extends FormRequest { @@ -25,7 +28,43 @@ public function rules(): array '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', ]; } + + /** + * Prevent changing the quota scope of plans that already affect assignments. + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + /** @var Plan|null $plan */ + $plan = $this->route('plan'); + $type = $this->input('type'); + + if (!$plan instanceof Plan || $type === null || $type === $plan->type) { + return; + } + + if ($plan->resources()->exists()) { + $validator->errors()->add('type', 'The plan type cannot be changed while resources are assigned.'); + } + + if ($plan->environments()->exists()) { + $validator->errors()->add('type', 'The plan type cannot be changed while environments use this plan.'); + } + + if (DB::table('tenants')->where('plan_id', $plan->id)->exists()) { + $validator->errors()->add('type', 'The plan type cannot be changed while tenants use this plan.'); + } + + if (DB::table('tenant_user')->where('plan_id', $plan->id)->exists()) { + $validator->errors()->add('type', 'The plan type cannot be changed while tenant users use this plan.'); + } + + if (DB::table('environment_user')->where('plan_id', $plan->id)->exists()) { + $validator->errors()->add('type', 'The plan type cannot be changed while environment users use this plan.'); + } + }); + } } diff --git a/packages/core/src/Models/Plan.php b/packages/core/src/Models/Plan.php index 54c8bef..70410eb 100644 --- a/packages/core/src/Models/Plan.php +++ b/packages/core/src/Models/Plan.php @@ -67,6 +67,8 @@ public static function getAllPermissions(): array ['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'], ]; } diff --git a/packages/core/src/Policies/PlanPolicy.php b/packages/core/src/Policies/PlanPolicy.php index 7e45327..ac378e8 100644 --- a/packages/core/src/Policies/PlanPolicy.php +++ b/packages/core/src/Policies/PlanPolicy.php @@ -55,6 +55,11 @@ public function resourceViewAny(User $user, Plan $plan): bool && $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 @@ -67,6 +72,12 @@ public function resourceDelete(User $user, Plan $plan): bool && $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); diff --git a/packages/core/tests/Feature/PlanAuthorizationTest.php b/packages/core/tests/Feature/PlanAuthorizationTest.php index c2b2ef2..9ce45b8 100644 --- a/packages/core/tests/Feature/PlanAuthorizationTest.php +++ b/packages/core/tests/Feature/PlanAuthorizationTest.php @@ -74,6 +74,68 @@ public function test_assigned_global_plan_cannot_be_deleted(): void $tenant->update(['plan_id' => $originalPlanId]); } + public function test_assigned_global_plan_type_cannot_be_changed(): 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') + ->putJson('/api/plans/' . $plan->id, [ + 'type' => 'environment', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['type']); + + $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(); 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/TenantEnvironmentPlanAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php index 3c25ca4..6d63381 100644 --- a/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php @@ -86,4 +86,29 @@ public function test_unassigned_user_cannot_manage_environment_plan(): void ->deleteJson($basePath . '/' . $plan->id) ->assertForbidden(); } + + public function test_assigned_environment_plan_cannot_be_deleted(): 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(); + $plan = Plan::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Assigned Environment Plan ' . str()->ulid(), + 'type' => 'environment', + ]); + $originalPlanId = $environment->plan_id; + + $environment->update(['plan_id' => $plan->id]); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/plans/' . $plan->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['plan']); + + $environment->update(['plan_id' => $originalPlanId]); + } } From 028371c31eea6ff269d846c7320dde133b375a6f Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Sun, 21 Jun 2026 11:31:57 +0200 Subject: [PATCH 4/8] - Tenant-owned plans are now required when assigning a plan to child tenants. Global plans are no longer directly reusable for child-tenant creation. - Added `tenant_resource_reservations` for delegated tenant-scope budget. Assigning a tenant-owned plan to a child tenant reserves the enabled plan limits from the parent tenant's available budget. - Parent tenant availability now subtracts real `tenant_usage` and existing child reservations before a child plan can be assigned. - Environment create/update now validates selected plans through the central plan-assignment guard using tenant availability, without a separate environment-plan grant table. - Added PHPUnit coverage for plan assignment scope checks, subset-limit checks, unlimited-above-finite denial, assigned-plan delete guards, plan-resource assignment, response metadata, parent-limit denial, foreign/global route isolation, unassigned detach validation, and audit logging. - Added PHPUnit coverage for the resource registry contract, duplicate key detection per scope, and automatic package resource seeding. - Core, domain, and mail testing seeders now attach package resources to the new deterministic test plans instead of relying on the old generic `Unlimited` plan name. Signed-off-by: Michael Kaufmann --- .../0001_01_01_000040_create_plans_table.php | 3 +- ...ate_tenant_resource_reservations_table.php | 37 ++++ .../seeders/PlansAndResourcesTableSeeder.php | 15 +- .../Testing/TenantAndUsersTableSeeder.php | 1 + packages/core/routes/api.php | 1 - .../core/src/Console/Commands/ListPlans.php | 4 +- .../Api/Plan/PlanResourceController.php | 4 +- .../Tenant/Environment/PlansController.php | 90 --------- .../Api/Tenant/EnvironmentController.php | 29 +-- .../Tenant/Plan/PlanResourceController.php | 4 +- .../Controllers/Api/Tenant/PlanController.php | 10 +- .../Http/Controllers/Api/TenantController.php | 58 +++++- .../src/Http/Requests/StorePlanRequest.php | 2 - .../StoreEnvironmentPlanRequest.php | 36 ---- .../src/Http/Requests/UpdatePlanRequest.php | 40 ---- packages/core/src/Models/Plan.php | 26 --- packages/core/src/Models/Tenant.php | 18 ++ .../src/Models/TenantResourceReservation.php | 48 +++++ packages/core/src/Policies/PlanPolicy.php | 37 ---- .../Environments/Schemas/EnvironmentView.php | 12 -- packages/core/src/Support/PlanAssignments.php | 191 ++++++++++++++++-- .../Feature/EnvironmentResourceUsageTest.php | 1 - .../tests/Feature/NodeResourceUsageTest.php | 1 - .../tests/Feature/PlanAuthorizationTest.php | 21 -- .../Feature/PlanResourceAuthorizationTest.php | 14 +- .../tests/Feature/TenantAuthorizationTest.php | 93 +++++++++ .../TenantEnvironmentAuthorizationTest.php | 37 +--- ...TenantEnvironmentPlanAuthorizationTest.php | 114 ----------- ...TenantEnvironmentUserAuthorizationTest.php | 19 +- .../Feature/TenantNodeAuthorizationTest.php | 5 +- .../Feature/TenantPlanAuthorizationTest.php | 4 - .../TenantPlanResourceAuthorizationTest.php | 7 +- .../Feature/TenantUserAuthorizationTest.php | 13 +- 33 files changed, 463 insertions(+), 532 deletions(-) create mode 100644 packages/core/database/migrations/0001_01_01_000073_create_tenant_resource_reservations_table.php delete mode 100644 packages/core/src/Http/Controllers/Api/Tenant/Environment/PlansController.php delete mode 100644 packages/core/src/Http/Requests/Tenant/Environment/StoreEnvironmentPlanRequest.php create mode 100644 packages/core/src/Models/TenantResourceReservation.php delete mode 100644 packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php 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..801b9d6 --- /dev/null +++ b/packages/core/database/migrations/0001_01_01_000073_create_tenant_resource_reservations_table.php @@ -0,0 +1,37 @@ +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->bigInteger('limit')->default(0); + $table->timestamps(); + + $table->unique(['tenant_id', 'reserved_for_tenant_id', 'resource_key'], '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/PlansAndResourcesTableSeeder.php b/packages/core/database/seeders/PlansAndResourcesTableSeeder.php index fcf3540..a75d867 100644 --- a/packages/core/database/seeders/PlansAndResourcesTableSeeder.php +++ b/packages/core/database/seeders/PlansAndResourcesTableSeeder.php @@ -81,7 +81,7 @@ public static function seedResourceCatalog(): void */ public static function createTenantPlan(string $name, array $limits, ?string $tenantId = null): Plan { - return self::createPlanWithResourceLimits($name, 'tenant', $limits, $tenantId); + return self::createPlanWithResourceLimits($name, $limits, $tenantId, 'tenant'); } /** @@ -91,7 +91,7 @@ public static function createTenantPlan(string $name, array $limits, ?string $te */ public static function createEnvironmentPlan(string $name, array $limits, ?string $tenantId = null): Plan { - return self::createPlanWithResourceLimits($name, 'environment', $limits, $tenantId); + return self::createPlanWithResourceLimits($name, $limits, $tenantId, 'environment'); } /** @@ -99,22 +99,25 @@ public static function createEnvironmentPlan(string $name, array $limits, ?strin * * @param array $limits Resource key to limit map. */ - private static function createPlanWithResourceLimits(string $name, string $type, array $limits, ?string $tenantId = null): Plan + 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, - 'type' => $type, 'name' => $name, ], [ 'description' => null, ]); - $resources = $type === 'tenant' ? self::tenantResources() : self::environmentResources(); + $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 ' . $type . ' resource key "' . $key . '" for plan "' . $name . '".'); + throw new \InvalidArgumentException('Unknown resource key "' . $key . '" for plan "' . $name . '".'); } $plan->resources()->syncWithoutDetaching([ diff --git a/packages/core/database/seeders/Testing/TenantAndUsersTableSeeder.php b/packages/core/database/seeders/Testing/TenantAndUsersTableSeeder.php index b4815b7..13f029b 100644 --- a/packages/core/database/seeders/Testing/TenantAndUsersTableSeeder.php +++ b/packages/core/database/seeders/Testing/TenantAndUsersTableSeeder.php @@ -94,6 +94,7 @@ public function run(): void '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', 'Test Tenant Limited')->first()->id ]); + } } diff --git a/packages/core/routes/api.php b/packages/core/routes/api.php index 93c2787..4b1a210 100644 --- a/packages/core/routes/api.php +++ b/packages/core/routes/api.php @@ -23,7 +23,6 @@ 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']); 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/Http/Controllers/Api/Plan/PlanResourceController.php b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php index 64c93a1..67549bd 100644 --- a/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php +++ b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php @@ -17,7 +17,7 @@ class PlanResourceController extends Controller { /** - * Display every resource matching the plan type and mark assigned resources. + * Display every available resource and mark assigned resources. */ public function index(Request $request, Plan $plan) { @@ -37,7 +37,7 @@ public function index(Request $request, Plan $plan) ]); $resources = Resource::query() - ->where('type', $plan->type) + ->orderBy('type') ->orderBy('key') ->get() ->map(function (Resource $resource) use ($assignedResources) { 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 349da60..0000000 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/PlansController.php +++ /dev/null @@ -1,90 +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]); - PlanAssignments::ensureNotAssigned($plan); - - $plan->delete(); - event(new ResourceDeleted($plan, [])); - - return response()->noContent(); - } -} diff --git a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php index 8c8515f..d99be27 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::ensurePlanAvailableForTenant($envData['plan_id'] ?? null, $tenant); // create resource $env = Environment::query()->create($envData); // build up validated data for others @@ -79,7 +79,7 @@ public function update(UpdateEnvironmentRequest $request, Tenant $tenant, Enviro $envData = $request->validated(); $nodeId = $this->getNonModelRequestData('node_id', $envData); - $this->ensurePlanCanBeUsedForEnvironment($envData['plan_id'] ?? null, $tenant); + PlanAssignments::ensurePlanAvailableForTenant($envData['plan_id'] ?? null, $tenant); $environment->update($envData); event(new ResourceUpdated($environment, $this->validatedEventData($request))); @@ -126,27 +126,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 index 984592d..502b49a 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php @@ -18,7 +18,7 @@ class PlanResourceController extends Controller { /** - * Display every tenant-usable resource matching the plan type and mark assignments. + * Display every available resource and mark assignments. */ public function index(Request $request, Tenant $tenant, Plan $plan) { @@ -38,7 +38,7 @@ public function index(Request $request, Tenant $tenant, Plan $plan) ]); $resources = Resource::query() - ->where('type', $plan->type) + ->orderBy('type') ->orderBy('key') ->get() ->map(function (Resource $resource) use ($assignedResources) { diff --git a/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php b/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php index 1aceb08..1da1f48 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/PlanController.php @@ -24,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); } diff --git a/packages/core/src/Http/Controllers/Api/TenantController.php b/packages/core/src/Http/Controllers/Api/TenantController.php index 1f49830..5c302fa 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,29 @@ 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']); + PlanAssignments::ensureAssignableToChildTenant($plan, $parentTenant); - $tenant->nodes()->syncWithoutDetaching([ - $node->id => ['inheritable' => (bool)($nodeData['inheritable'] ?? false)], - ]); - } + $tenant = DB::transaction(function () use ($tenantData, $nodes, $parentTenant, $plan) { + // 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 +88,31 @@ 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; + + if ($parentTenant !== null) { + PlanAssignments::ensureAssignableToChildTenant($plan, $parentTenant, $tenant); + } + + DB::transaction(function () use ($tenant, $tenantData, $oldParentTenant, $parentTenant, $plan): void { + 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/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/UpdatePlanRequest.php b/packages/core/src/Http/Requests/UpdatePlanRequest.php index 2bc620e..5a5005c 100644 --- a/packages/core/src/Http/Requests/UpdatePlanRequest.php +++ b/packages/core/src/Http/Requests/UpdatePlanRequest.php @@ -2,10 +2,7 @@ namespace Froxlor\Core\Http\Requests; -use Froxlor\Core\Models\Plan; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\DB; -use Illuminate\Validation\Validator; class UpdatePlanRequest extends FormRequest { @@ -26,45 +23,8 @@ public function rules(): array { return [ 'name' => 'sometimes|string', - 'type' => 'sometimes|string|in:tenant,environment', 'description' => 'sometimes|nullable|string', 'tenant_id' => 'prohibited', ]; } - - /** - * Prevent changing the quota scope of plans that already affect assignments. - */ - public function withValidator(Validator $validator): void - { - $validator->after(function (Validator $validator): void { - /** @var Plan|null $plan */ - $plan = $this->route('plan'); - $type = $this->input('type'); - - if (!$plan instanceof Plan || $type === null || $type === $plan->type) { - return; - } - - if ($plan->resources()->exists()) { - $validator->errors()->add('type', 'The plan type cannot be changed while resources are assigned.'); - } - - if ($plan->environments()->exists()) { - $validator->errors()->add('type', 'The plan type cannot be changed while environments use this plan.'); - } - - if (DB::table('tenants')->where('plan_id', $plan->id)->exists()) { - $validator->errors()->add('type', 'The plan type cannot be changed while tenants use this plan.'); - } - - if (DB::table('tenant_user')->where('plan_id', $plan->id)->exists()) { - $validator->errors()->add('type', 'The plan type cannot be changed while tenant users use this plan.'); - } - - if (DB::table('environment_user')->where('plan_id', $plan->id)->exists()) { - $validator->errors()->add('type', 'The plan type cannot be changed while environment users use this plan.'); - } - }); - } } diff --git a/packages/core/src/Models/Plan.php b/packages/core/src/Models/Plan.php index 70410eb..6a1da65 100644 --- a/packages/core/src/Models/Plan.php +++ b/packages/core/src/Models/Plan.php @@ -5,7 +5,6 @@ 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; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -17,7 +16,6 @@ /** * @property string $id * @property string|null $tenant_id - * @property string $type * @property string $name * @property string|null $description * @property Carbon $created_at @@ -72,14 +70,6 @@ public static function getAllPermissions(): array ]; } - /** - * Limit the query to plans usable for environments. - */ - public function scopeEnvironment(Builder $query): Builder - { - return $query->where('type', 'environment'); - } - /** * Limit the query to plans usable by the given tenant. * @@ -94,22 +84,6 @@ public function scopeAvailableForTenant(Builder $query, Tenant $tenant): Builder }); } - /** - * Check whether this plan can be assigned to tenant users. - */ - public function isTenantPlan(): bool - { - return $this->type === 'tenant'; - } - - /** - * 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/Tenant.php b/packages/core/src/Models/Tenant.php index dbae56d..0db57a2 100644 --- a/packages/core/src/Models/Tenant.php +++ b/packages/core/src/Models/Tenant.php @@ -31,6 +31,8 @@ * @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 @@ -87,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( diff --git a/packages/core/src/Models/TenantResourceReservation.php b/packages/core/src/Models/TenantResourceReservation.php new file mode 100644 index 0000000..2956dbb --- /dev/null +++ b/packages/core/src/Models/TenantResourceReservation.php @@ -0,0 +1,48 @@ +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/Policies/PlanPolicy.php b/packages/core/src/Policies/PlanPolicy.php index ac378e8..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; @@ -142,40 +141,4 @@ public function tenantResourceDelete(User $user, Plan $plan, Tenant $tenant): bo return $this->hasScopedPermission($user, 'tenants.plans.resources.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 - { - if ($plan->tenant_id !== $tenant->id || $plan->type !== 'environment') { - return false; - } - - return $this->hasScopedPermission($user, 'tenants.environments.plans.index', $tenant, $environment); - } - - public function tenantEnvCreate(User $user, Tenant $tenant, Environment $environment): 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') { - return false; - } - - return $this->hasScopedPermission($user, 'tenants.environments.plans.update', $tenant, $environment); - } - - public function tenantEnvDelete(User $user, Plan $plan, Tenant $tenant, Environment $environment): bool - { - if ($plan->tenant_id !== $tenant->id || $plan->type !== 'environment') { - return false; - } - - return $this->hasScopedPermission($user, 'tenants.environments.plans.destroy', $tenant, $environment); - } } 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/Support/PlanAssignments.php b/packages/core/src/Support/PlanAssignments.php index 19197ba..9142541 100644 --- a/packages/core/src/Support/PlanAssignments.php +++ b/packages/core/src/Support/PlanAssignments.php @@ -6,6 +6,7 @@ use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Models\TenantResourceReservation; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -15,8 +16,8 @@ class PlanAssignments * Ensure that a plan can be assigned as an optional tenant-user limit plan. * * Tenant users inherit the tenant plan when no explicit plan is assigned. When an - * explicit plan is assigned, it must be a tenant-scope plan available in the tenant - * context and it must not grant more resources than the tenant's own plan. + * explicit plan is assigned, it must be available in the tenant context and its + * tenant-scope resources must not exceed the tenant's own plan. * * @throws ValidationException */ @@ -28,11 +29,11 @@ public static function ensureAssignableToTenantUser(?string $planId, Tenant $ten $plan = Plan::query()->with('resources')->findOrFail($planId); - if (!$plan->isTenantPlan() || !$plan->isAvailableForTenant($tenant)) { + if (!$plan->isAvailableForTenant($tenant)) { throw self::validationException($field, 'The selected plan is not available for this tenant.'); } - self::ensureWithinParentPlan($plan, $tenant->plan, $field); + self::ensureWithinParentPlan($plan, $tenant->plan, $field, 'tenant'); } /** @@ -56,11 +57,11 @@ public static function ensureAssignableToEnvironmentUser( $plan = Plan::query()->with('resources')->findOrFail($planId); - if (!$plan->isEnvironmentPlan() || !$plan->isAvailableForTenant($tenant)) { + if (!$plan->isAvailableForTenant($tenant)) { throw self::validationException($field, 'The selected plan is not available for this environment.'); } - self::ensureWithinParentPlan($plan, $environment->plan, $field); + self::ensureWithinParentPlan($plan, $environment->plan, $field, 'environment'); } /** @@ -79,6 +80,7 @@ public static function ensureNotAssigned(Plan $plan): void '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) @@ -94,9 +96,10 @@ public static function ensureNotAssigned(Plan $plan): void /** * Ensure a resource can be attached to the given plan with the requested limit. * - * Plan resources must match the plan scope. For tenant-owned tenant plans, the - * resource limit must also stay within the owning tenant's assigned plan so tenants - * cannot create child plans that grant more tenant-scope capacity than they own. + * For tenant-owned plans, tenant-scope resource limits must stay within the owning + * tenant's assigned plan so tenants cannot create child plans that grant more + * tenant-scope capacity than they own. Environment-scope resources are ignored by + * tenant budget reservations and are checked when assigned in an environment context. * * @throws ValidationException */ @@ -107,11 +110,7 @@ public static function ensureResourceCanBeAttached( ?Tenant $tenant = null, string $field = 'resource_id', ): void { - if ($resource->type !== $plan->type) { - throw self::validationException($field, 'The selected resource is not available for this plan type.'); - } - - if ($tenant === null || !$plan->isTenantPlan() || $limit === 0) { + if ($tenant === null || $resource->type !== 'tenant' || $limit === 0) { return; } @@ -122,6 +121,7 @@ public static function ensureResourceCanBeAttached( $parentResource = $parentPlan->resources() ->where('resources.key', $resource->key) + ->where('resources.type', 'tenant') ->first(); $parentLimit = $parentResource === null ? null : (int)$parentResource->pivot->limit; @@ -148,17 +148,22 @@ public static function ensureResourceCanBeAttached( * * @throws ValidationException */ - public static function ensureWithinParentPlan(Plan $childPlan, ?Plan $parentPlan, string $field = 'plan_id'): void + public static function ensureWithinParentPlan(Plan $childPlan, ?Plan $parentPlan, string $field = 'plan_id', ?string $resourceType = null): void { if ($parentPlan === null) { throw self::validationException($field, 'The selected plan cannot be assigned without a parent plan.'); } $parentResources = $parentPlan->resources() + ->when($resourceType !== null, fn($query) => $query->where('resources.type', $resourceType)) ->get() ->mapWithKeys(fn($resource) => [$resource->key => (int)$resource->pivot->limit]); - foreach ($childPlan->resources as $childResource) { + $childResources = $childPlan->resources() + ->when($resourceType !== null, fn($query) => $query->where('resources.type', $resourceType)) + ->get(); + + foreach ($childResources as $childResource) { $childLimit = (int)$childResource->pivot->limit; if ($childLimit === 0) { @@ -181,6 +186,160 @@ public static function ensureWithinParentPlan(Plan $childPlan, ?Plan $parentPlan } } + /** + * 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 tenant-scope resources must fit within + * the parent's own plan, and those tenant 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, 'tenant'); + self::ensureWithinAvailableTenantBudget($plan, $parentTenant, $childTenant, $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])); + } + } + + /** + * Persist reservations for every enabled tenant-scope 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, 'tenant') as $resourceKey => $limit) { + if ($limit === 0) { + continue; + } + + TenantResourceReservation::query()->create([ + 'tenant_id' => $parentTenant->id, + 'reserved_for_tenant_id' => $childTenant->id, + 'plan_id' => $plan->id, + 'resource_key' => $resourceKey, + 'limit' => $limit, + ]); + } + } + + /** + * 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, 'tenant') as $resourceKey => $limit) { + if ($limit === -1) { + $budget[$resourceKey] = -1; + continue; + } + + $used = DB::table('tenant_usage') + ->where('tenant_id', $tenant->id) + ->where('resource_key', $resourceKey) + ->count(); + + $reserved = TenantResourceReservation::query() + ->where('tenant_id', $tenant->id) + ->where('resource_key', $resourceKey) + ->when($ignoreChildTenant !== null, fn($query) => $query->where('reserved_for_tenant_id', '!=', $ignoreChildTenant->id)) + ->sum('limit'); + + $budget[$resourceKey] = 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, 'tenant') as $resourceKey => $limit) { + if ($limit === 0) { + continue; + } + + $availableLimit = $available[$resourceKey] ?? 0; + + if ($availableLimit === -1) { + continue; + } + + if ($limit === -1 || $limit > $availableLimit) { + throw self::validationException($field, 'The selected plan exceeds the parent tenant available resource budget.'); + } + } + } + + /** + * Return resource limits keyed by resource key for the given plan. + * + * @return array + */ + private static function planLimits(Plan $plan, ?string $resourceType = null): array + { + return $plan->resources() + ->when($resourceType !== null, fn($query) => $query->where('resources.type', $resourceType)) + ->get() + ->mapWithKeys(fn($resource) => [$resource->key => (int)$resource->pivot->limit]) + ->all(); + } + private static function validationException(string $field, string $message): ValidationException { return ValidationException::withMessages([ diff --git a/packages/core/tests/Feature/EnvironmentResourceUsageTest.php b/packages/core/tests/Feature/EnvironmentResourceUsageTest.php index 29abef7..1668754 100644 --- a/packages/core/tests/Feature/EnvironmentResourceUsageTest.php +++ b/packages/core/tests/Feature/EnvironmentResourceUsageTest.php @@ -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]); diff --git a/packages/core/tests/Feature/NodeResourceUsageTest.php b/packages/core/tests/Feature/NodeResourceUsageTest.php index 4603ea0..1da365d 100644 --- a/packages/core/tests/Feature/NodeResourceUsageTest.php +++ b/packages/core/tests/Feature/NodeResourceUsageTest.php @@ -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]); diff --git a/packages/core/tests/Feature/PlanAuthorizationTest.php b/packages/core/tests/Feature/PlanAuthorizationTest.php index 9ce45b8..a0e6258 100644 --- a/packages/core/tests/Feature/PlanAuthorizationTest.php +++ b/packages/core/tests/Feature/PlanAuthorizationTest.php @@ -36,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() @@ -74,25 +73,6 @@ public function test_assigned_global_plan_cannot_be_deleted(): void $tenant->update(['plan_id' => $originalPlanId]); } - public function test_assigned_global_plan_type_cannot_be_changed(): 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') - ->putJson('/api/plans/' . $plan->id, [ - 'type' => 'environment', - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['type']); - - $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(); @@ -148,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 index 56a0af8..fd8925f 100644 --- a/packages/core/tests/Feature/PlanResourceAuthorizationTest.php +++ b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php @@ -13,7 +13,7 @@ class PlanResourceAuthorizationTest extends TestCase public function test_super_admin_can_manage_global_plan_resources(): void { $user = User::query()->where('email', config('dev.email'))->firstOrFail(); - $plan = Plan::query()->whereNull('tenant_id')->where('type', 'tenant')->where('name', 'Tenant Starter')->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'; @@ -51,7 +51,7 @@ public function test_super_admin_can_manage_global_plan_resources(): void 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('type', 'tenant')->where('name', 'Tenant Starter')->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(); @@ -80,7 +80,6 @@ public function test_plan_resource_route_rejects_tenant_plans(): void $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $plan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Global Route Tenant Plan ' . str()->ulid(), ]); $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); @@ -97,10 +96,10 @@ public function test_plan_resource_route_rejects_tenant_plans(): void ->assertForbidden(); } - public function test_plan_resource_type_must_match_plan_type(): void + 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('type', 'tenant')->where('name', 'Tenant Starter')->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') @@ -108,14 +107,13 @@ public function test_plan_resource_type_must_match_plan_type(): void 'resource_id' => $resource->id, 'limit' => 1, ]) - ->assertUnprocessable() - ->assertJsonValidationErrors(['resource_id']); + ->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('type', 'tenant')->where('name', 'Tenant Starter')->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); diff --git a/packages/core/tests/Feature/TenantAuthorizationTest.php b/packages/core/tests/Feature/TenantAuthorizationTest.php index 52cb2f3..5da4812 100644 --- a/packages/core/tests/Feature/TenantAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantAuthorizationTest.php @@ -2,7 +2,10 @@ 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 Tests\TestCase; @@ -77,4 +80,94 @@ 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, + '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, + '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']); + } } diff --git a/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php index d519e20..e8c5f90 100644 --- a/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php @@ -21,6 +21,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 +165,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 +182,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 +195,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,13 +204,6 @@ 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 @@ -235,12 +212,10 @@ public function test_tenant_admin_can_assign_available_environment_plan_on_creat $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $tenantPlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'environment', 'name' => 'Available Environment Plan ' . str()->ulid(), ]); $globalPlan = Plan::query()->create([ 'tenant_id' => null, - 'type' => 'environment', 'name' => 'Global Available Environment Plan ' . str()->ulid(), ]); diff --git a/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php deleted file mode 100644 index 6d63381..0000000 --- a/packages/core/tests/Feature/TenantEnvironmentPlanAuthorizationTest.php +++ /dev/null @@ -1,114 +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(); - } - - public function test_assigned_environment_plan_cannot_be_deleted(): 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(); - $plan = Plan::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Assigned Environment Plan ' . str()->ulid(), - 'type' => 'environment', - ]); - $originalPlanId = $environment->plan_id; - - $environment->update(['plan_id' => $plan->id]); - - $this->actingAs($user, 'sanctum') - ->deleteJson('/api/tenants/' . $tenant->id . '/environments/' . $environment->id . '/plans/' . $plan->id) - ->assertUnprocessable() - ->assertJsonValidationErrors(['plan']); - - $environment->update(['plan_id' => $originalPlanId]); - } -} diff --git a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php index e095140..82219bb 100644 --- a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php @@ -136,7 +136,6 @@ public function test_environment_user_plan_must_be_environment_scope_and_within_ $parentPlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'environment', 'name' => 'Environment Parent User Plan ' . str()->ulid(), ]); $parentPlan->resources()->attach($resource, ['limit' => 2]); @@ -144,24 +143,20 @@ public function test_environment_user_plan_must_be_environment_scope_and_within_ $validPlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'environment', 'name' => 'Environment Child User Plan ' . str()->ulid(), ]); $validPlan->resources()->attach($resource, ['limit' => 1]); $tooLargePlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'environment', 'name' => 'Environment Too Large User Plan ' . str()->ulid(), ]); $tooLargePlan->resources()->attach($resource, ['limit' => 3]); - - $wrongTypePlan = Plan::query()->create([ - 'tenant_id' => $tenant->id, - 'type' => 'tenant', - 'name' => 'Wrong Environment User Plan Type ' . str()->ulid(), + $foreignPlan = Plan::query()->create([ + 'tenant_id' => Tenant::query()->where('name', 'Kunde #2')->firstOrFail()->id, + 'name' => 'Foreign Environment User Plan ' . str()->ulid(), ]); - $wrongTypePlan->resources()->attach($resource, ['limit' => 1]); + $foreignPlan->resources()->attach($resource, ['limit' => 1]); $this->actingAs($user, 'sanctum') ->postJson($basePath, [ @@ -191,12 +186,12 @@ public function test_environment_user_plan_must_be_environment_scope_and_within_ $this->actingAs($user, 'sanctum') ->postJson($basePath, [ 'first_name' => 'Forbidden', - 'last_name' => 'Wrong Environment Plan User', - 'email' => 'environment-wrong-plan-user-' . str()->ulid() . '@froxlor.test', + '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' => $wrongTypePlan->id, + 'environment_plan' => $foreignPlan->id, ]) ->assertUnprocessable() ->assertJsonValidationErrors(['environment_plan']); diff --git a/packages/core/tests/Feature/TenantNodeAuthorizationTest.php b/packages/core/tests/Feature/TenantNodeAuthorizationTest.php index d4e8ff2..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', 'Test Tenant Limited')->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 3171abc..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() @@ -49,7 +48,6 @@ public function test_assigned_tenant_plan_cannot_be_deleted(): void $plan = Plan::query()->create([ 'tenant_id' => $tenant->id, 'name' => 'Assigned Tenant Plan ' . str()->ulid(), - 'type' => 'tenant', ]); $originalPlanId = $tenant->plan_id; @@ -70,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') @@ -84,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 index a513b98..de6a0c3 100644 --- a/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php @@ -16,7 +16,6 @@ public function test_tenant_admin_can_manage_tenant_plan_resources(): void $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $plan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Tenant Resource Plan ' . str()->ulid(), ]); $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); @@ -54,7 +53,6 @@ public function test_tenant_plan_resource_index_lists_assigned_and_unassigned_re $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $plan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Tenant Resource Listing Plan ' . str()->ulid(), ]); $assignedResource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); @@ -84,10 +82,9 @@ public function test_tenant_plan_resource_route_rejects_foreign_and_global_plans $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); $foreignPlan = Plan::query()->create([ 'tenant_id' => $otherTenant->id, - 'type' => 'tenant', 'name' => 'Foreign Tenant Resource Plan ' . str()->ulid(), ]); - $globalPlan = Plan::query()->whereNull('tenant_id')->where('type', 'tenant')->firstOrFail(); + $globalPlan = Plan::query()->whereNull('tenant_id')->firstOrFail(); $this->actingAs($user, 'sanctum') ->getJson('/api/tenants/' . $tenant->id . '/plans/' . $foreignPlan->id . '/resources') @@ -110,7 +107,6 @@ public function test_tenant_plan_resource_limit_must_not_exceed_tenant_plan(): v ]); $plan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Tenant Resource Limit Plan ' . str()->ulid(), ]); $resource = Resource::query()->where('type', 'tenant')->where('key', 'users')->firstOrFail(); @@ -130,7 +126,6 @@ public function test_detaching_unassigned_tenant_plan_resource_returns_validatio $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); $plan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Unassigned Tenant Resource Plan ' . str()->ulid(), ]); $resource = Resource::query()->where('type', 'tenant')->where('key', 'roles')->firstOrFail(); diff --git a/packages/core/tests/Feature/TenantUserAuthorizationTest.php b/packages/core/tests/Feature/TenantUserAuthorizationTest.php index 5147a95..f68ec9b 100644 --- a/packages/core/tests/Feature/TenantUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantUserAuthorizationTest.php @@ -76,7 +76,6 @@ public function test_tenant_user_plan_must_be_tenant_scope_and_within_tenant_pla $parentPlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Tenant Parent User Plan ' . str()->ulid(), ]); $parentPlan->resources()->attach($resource, ['limit' => 2]); @@ -84,32 +83,22 @@ public function test_tenant_user_plan_must_be_tenant_scope_and_within_tenant_pla $validPlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Tenant Child User Plan ' . str()->ulid(), ]); $validPlan->resources()->attach($resource, ['limit' => 1]); $tooLargePlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Tenant Too Large User Plan ' . str()->ulid(), ]); $tooLargePlan->resources()->attach($resource, ['limit' => 3]); $unlimitedPlan = Plan::query()->create([ 'tenant_id' => $tenant->id, - 'type' => 'tenant', 'name' => 'Tenant Unlimited User Plan ' . str()->ulid(), ]); $unlimitedPlan->resources()->attach($resource, ['limit' => -1]); - $wrongTypePlan = Plan::query()->create([ - 'tenant_id' => $tenant->id, - 'type' => 'environment', - 'name' => 'Wrong Tenant User Plan Type ' . str()->ulid(), - ]); - $wrongTypePlan->resources()->attach($resource, ['limit' => 1]); - $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/users', [ 'first_name' => 'Tenant', @@ -152,7 +141,7 @@ public function test_tenant_user_plan_must_be_tenant_scope_and_within_tenant_pla 'email' => 'tenant-wrong-type-plan-user-' . str()->ulid() . '@froxlor.test', 'password' => 'secret-password', 'role_id' => $role->id, - 'plan_id' => $wrongTypePlan->id, + 'plan_id' => Plan::query()->whereNull('tenant_id')->firstOrFail()->id, ]) ->assertUnprocessable() ->assertJsonValidationErrors(['plan_id']); From 9a4165efcf1355d1ba50c344cd522f8eade8a72a Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Sun, 21 Jun 2026 20:47:14 +0200 Subject: [PATCH 5/8] - `tenant_resource_reservations` now stores the `resource_type` to prevent collisions between identical keys across different scopes. - Child tenant reservations now capture all enabled plan resources, not just those with `type=tenant`. - Tenant/environment user plan assignments also compare all plan resources against the parent plan. - Actual usage remains scope-specific: tenant resources via `tenant_usage`, and environment resources via `env_usage`. Signed-off-by: Michael Kaufmann --- ...ate_tenant_resource_reservations_table.php | 3 +- packages/core/src/Models/EnvironmentUser.php | 5 +- packages/core/src/Models/Plan.php | 1 + .../src/Models/TenantResourceReservation.php | 8 +- packages/core/src/Models/TenantUser.php | 5 +- packages/core/src/Support/PlanAssignments.php | 122 ++++++++++++------ packages/core/src/Support/Resource.php | 3 +- .../tests/Feature/TenantAuthorizationTest.php | 40 ++++++ ...TenantEnvironmentUserAuthorizationTest.php | 2 +- .../Feature/TenantUserAuthorizationTest.php | 2 +- 10 files changed, 144 insertions(+), 47 deletions(-) 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 index 801b9d6..322f266 100644 --- 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 @@ -16,10 +16,11 @@ public function up(): void $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'], 'tenant_resource_reservation_unique'); + $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'); 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/Plan.php b/packages/core/src/Models/Plan.php index 6a1da65..836c64a 100644 --- a/packages/core/src/Models/Plan.php +++ b/packages/core/src/Models/Plan.php @@ -5,6 +5,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; use Illuminate\Database\Eloquent\Relations\BelongsTo; diff --git a/packages/core/src/Models/TenantResourceReservation.php b/packages/core/src/Models/TenantResourceReservation.php index 2956dbb..01f8e96 100644 --- a/packages/core/src/Models/TenantResourceReservation.php +++ b/packages/core/src/Models/TenantResourceReservation.php @@ -8,16 +8,18 @@ use Illuminate\Support\Carbon; /** - * Reserved tenant-scope quota delegated from a tenant to one direct child tenant. + * Reserved quota delegated from a tenant to one direct child tenant. * - * Actual usage remains in `tenant_usage`; reservations represent budget that is no - * longer available to the parent because it has been assigned to a child tenant. + * Actual usage remains in the scope-specific usage tables; reservations represent + * budget that is no longer available to the parent because it has been assigned to a + * child tenant. * * @property string $id * @property string $tenant_id * @property string $reserved_for_tenant_id * @property string $plan_id * @property string $resource_key + * @property string $resource_type * @property int $limit * @property Carbon $created_at * @property Carbon $updated_at 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/Support/PlanAssignments.php b/packages/core/src/Support/PlanAssignments.php index 9142541..1ff8873 100644 --- a/packages/core/src/Support/PlanAssignments.php +++ b/packages/core/src/Support/PlanAssignments.php @@ -17,7 +17,7 @@ class PlanAssignments * * Tenant users inherit the tenant plan when no explicit plan is assigned. When an * explicit plan is assigned, it must be available in the tenant context and its - * tenant-scope resources must not exceed the tenant's own plan. + * resources must not exceed the tenant's own plan. * * @throws ValidationException */ @@ -33,7 +33,8 @@ public static function ensureAssignableToTenantUser(?string $planId, Tenant $ten throw self::validationException($field, 'The selected plan is not available for this tenant.'); } - self::ensureWithinParentPlan($plan, $tenant->plan, $field, 'tenant'); + self::ensureHasEnabledResources($plan, $field, 'The selected plan does not contain enabled resources.'); + self::ensureWithinParentPlan($plan, $tenant->plan, $field); } /** @@ -61,7 +62,8 @@ public static function ensureAssignableToEnvironmentUser( throw self::validationException($field, 'The selected plan is not available for this environment.'); } - self::ensureWithinParentPlan($plan, $environment->plan, $field, 'environment'); + self::ensureHasEnabledResources($plan, $field, 'The selected plan does not contain enabled resources.'); + self::ensureWithinParentPlan($plan, $environment->plan, $field); } /** @@ -96,10 +98,9 @@ public static function ensureNotAssigned(Plan $plan): void /** * Ensure a resource can be attached to the given plan with the requested limit. * - * For tenant-owned plans, tenant-scope resource limits must stay within the owning + * For tenant-owned plans, resource limits must stay within the owning * tenant's assigned plan so tenants cannot create child plans that grant more - * tenant-scope capacity than they own. Environment-scope resources are ignored by - * tenant budget reservations and are checked when assigned in an environment context. + * capacity than they own. * * @throws ValidationException */ @@ -110,7 +111,7 @@ public static function ensureResourceCanBeAttached( ?Tenant $tenant = null, string $field = 'resource_id', ): void { - if ($tenant === null || $resource->type !== 'tenant' || $limit === 0) { + if ($tenant === null || $limit === 0) { return; } @@ -121,7 +122,7 @@ public static function ensureResourceCanBeAttached( $parentResource = $parentPlan->resources() ->where('resources.key', $resource->key) - ->where('resources.type', 'tenant') + ->where('resources.type', $resource->type) ->first(); $parentLimit = $parentResource === null ? null : (int)$parentResource->pivot->limit; @@ -148,20 +149,17 @@ public static function ensureResourceCanBeAttached( * * @throws ValidationException */ - public static function ensureWithinParentPlan(Plan $childPlan, ?Plan $parentPlan, string $field = 'plan_id', ?string $resourceType = null): void + 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() - ->when($resourceType !== null, fn($query) => $query->where('resources.type', $resourceType)) ->get() - ->mapWithKeys(fn($resource) => [$resource->key => (int)$resource->pivot->limit]); + ->mapWithKeys(fn($resource) => [self::resourceIdentifier($resource->key, $resource->type) => (int)$resource->pivot->limit]); - $childResources = $childPlan->resources() - ->when($resourceType !== null, fn($query) => $query->where('resources.type', $resourceType)) - ->get(); + $childResources = $childPlan->resources()->get(); foreach ($childResources as $childResource) { $childLimit = (int)$childResource->pivot->limit; @@ -170,7 +168,7 @@ public static function ensureWithinParentPlan(Plan $childPlan, ?Plan $parentPlan continue; } - $parentLimit = $parentResources->get($childResource->key); + $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.'); @@ -191,8 +189,8 @@ public static function ensureWithinParentPlan(Plan $childPlan, ?Plan $parentPlan * * 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 tenant-scope resources must fit within - * the parent's own plan, and those tenant resources must fit into the parent's + * 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. * @@ -204,7 +202,7 @@ public static function ensureAssignableToChildTenant(Plan $plan, Tenant $parentT throw self::validationException($field, 'The selected plan is not available for child tenants.'); } - self::ensureWithinParentPlan($plan->loadMissing('resources'), $parentTenant->plan, $field, 'tenant'); + self::ensureWithinParentPlan($plan->loadMissing('resources'), $parentTenant->plan, $field); self::ensureWithinAvailableTenantBudget($plan, $parentTenant, $childTenant, $field); } @@ -227,7 +225,7 @@ public static function ensurePlanAvailableForTenant(?string $planId, Tenant $ten } /** - * Persist reservations for every enabled tenant-scope resource in the plan. + * 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. @@ -239,8 +237,8 @@ public static function syncTenantReservations(Tenant $parentTenant, Tenant $chil ->where('reserved_for_tenant_id', $childTenant->id) ->delete(); - foreach (self::planLimits($plan, 'tenant') as $resourceKey => $limit) { - if ($limit === 0) { + foreach (self::planLimits($plan) as $resourceLimit) { + if ($resourceLimit['limit'] === 0) { continue; } @@ -248,8 +246,9 @@ public static function syncTenantReservations(Tenant $parentTenant, Tenant $chil 'tenant_id' => $parentTenant->id, 'reserved_for_tenant_id' => $childTenant->id, 'plan_id' => $plan->id, - 'resource_key' => $resourceKey, - 'limit' => $limit, + 'resource_key' => $resourceLimit['key'], + 'resource_type' => $resourceLimit['type'], + 'limit' => $resourceLimit['limit'], ]); } } @@ -277,24 +276,24 @@ public static function availableTenantBudget(Tenant $tenant, ?Tenant $ignoreChil { $budget = []; - foreach (self::planLimits($tenant->plan, 'tenant') as $resourceKey => $limit) { + foreach (self::planLimits($tenant->plan) as $identifier => $resourceLimit) { + $limit = $resourceLimit['limit']; + if ($limit === -1) { - $budget[$resourceKey] = -1; + $budget[$identifier] = -1; continue; } - $used = DB::table('tenant_usage') - ->where('tenant_id', $tenant->id) - ->where('resource_key', $resourceKey) - ->count(); + $used = self::usageForTenant($tenant, $resourceLimit['key'], $resourceLimit['type']); $reserved = TenantResourceReservation::query() ->where('tenant_id', $tenant->id) - ->where('resource_key', $resourceKey) + ->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[$resourceKey] = max(0, $limit - $used - (int)$reserved); + $budget[$identifier] = max(0, $limit - $used - (int)$reserved); } return $budget; @@ -309,12 +308,14 @@ private static function ensureWithinAvailableTenantBudget(Plan $plan, Tenant $pa { $available = self::availableTenantBudget($parentTenant, $childTenant); - foreach (self::planLimits($plan, 'tenant') as $resourceKey => $limit) { + foreach (self::planLimits($plan) as $identifier => $resourceLimit) { + $limit = $resourceLimit['limit']; + if ($limit === 0) { continue; } - $availableLimit = $available[$resourceKey] ?? 0; + $availableLimit = $available[$identifier] ?? 0; if ($availableLimit === -1) { continue; @@ -327,19 +328,64 @@ private static function ensureWithinAvailableTenantBudget(Plan $plan, Tenant $pa } /** - * Return resource limits keyed by resource key for the given plan. + * Return resource limits keyed by resource type and key for the given plan. * - * @return array + * @return array */ - private static function planLimits(Plan $plan, ?string $resourceType = null): array + private static function planLimits(Plan $plan): array { return $plan->resources() - ->when($resourceType !== null, fn($query) => $query->where('resources.type', $resourceType)) ->get() - ->mapWithKeys(fn($resource) => [$resource->key => (int)$resource->pivot->limit]) + ->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): int + { + if ($resourceType === 'environment') { + return DB::table('env_usage') + ->join('environments', 'env_usage.environment_id', '=', 'environments.id') + ->where('environments.tenant_id', $tenant->id) + ->where('env_usage.resource_key', $resourceKey) + ->count(); + } + + return DB::table('tenant_usage') + ->where('tenant_id', $tenant->id) + ->where('resource_key', $resourceKey) + ->count(); + } + private static function validationException(string $field, string $message): ValidationException { return ValidationException::withMessages([ 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/tests/Feature/TenantAuthorizationTest.php b/packages/core/tests/Feature/TenantAuthorizationTest.php index 5da4812..ef75265 100644 --- a/packages/core/tests/Feature/TenantAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantAuthorizationTest.php @@ -114,6 +114,45 @@ public function test_child_tenant_creation_reserves_parent_budget(): void '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, ]); } @@ -142,6 +181,7 @@ public function test_child_tenant_creation_rejects_plan_above_available_parent_b 'reserved_for_tenant_id' => Tenant::query()->where('name', 'Kunde #2')->firstOrFail()->id, 'plan_id' => $plan->id, 'resource_key' => $resourceKey, + 'resource_type' => 'tenant', 'limit' => 1, ]); diff --git a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php index 82219bb..2aea704 100644 --- a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php @@ -121,7 +121,7 @@ public function test_environment_admin_cannot_assign_tenant_role_without_delegat ->assertJsonValidationErrors(['tenant_role']); } - public function test_environment_user_plan_must_be_environment_scope_and_within_environment_plan(): void + public function test_environment_user_plan_must_stay_within_environment_plan(): void { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $environment = Environment::query() diff --git a/packages/core/tests/Feature/TenantUserAuthorizationTest.php b/packages/core/tests/Feature/TenantUserAuthorizationTest.php index f68ec9b..c01af13 100644 --- a/packages/core/tests/Feature/TenantUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantUserAuthorizationTest.php @@ -67,7 +67,7 @@ public function test_tenant_admin_can_assign_tenant_owned_role_to_tenant_user(): ->assertCreated(); } - public function test_tenant_user_plan_must_be_tenant_scope_and_within_tenant_plan(): void + 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(); From 99320f4e41adadb2342c8b8e5ef5fa68c372fb48 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Mon, 22 Jun 2026 23:12:03 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=20=20-=20Limits=20k=C3=B6nnen=20erh=C3=B6h?= =?UTF-8?q?t=20werden,=20wenn=20das=20Parent-Kontingent=20reicht.=20=20=20?= =?UTF-8?q?-=20Limits=20k=C3=B6nnen=20reduziert=20werden,=20solange=20best?= =?UTF-8?q?ehende=20Usage=20und=20weiterdelegierte=20Reservations=20nicht?= =?UTF-8?q?=20dar=C3=BCber=20liegen.=20=20=20-=20Entfernen=20einer=20Resou?= =?UTF-8?q?rce=20wird=20abgelehnt,=20wenn=20daf=C3=BCr=20bereits=20Usage?= =?UTF-8?q?=20oder=20Delegation=20existiert.=20=20=20-=20Child-Tenant-Rese?= =?UTF-8?q?rvations=20werden=20nach=20erfolgreicher=20Plan=C3=A4nderung=20?= =?UTF-8?q?synchronisiert.=20=20=20-=20Die=20alte=20pauschale=20Sperre=20f?= =?UTF-8?q?=C3=BCr=20zugewiesene=20Pl=C3=A4ne=20ist=20entfernt.=20=20=20-?= =?UTF-8?q?=20Global=20zugewiesene=20Pl=C3=A4ne=20werden=20gegen=20aktuell?= =?UTF-8?q?e=20Usage=20gepr=C3=BCft,=20aber=20nicht=20f=C3=A4lschlich=20al?= =?UTF-8?q?s=20Child-Tenant-Reservation=20behandelt.=20=20=20-=20Tenant-ow?= =?UTF-8?q?ned=20Child-Pl=C3=A4ne=20aktualisieren=20die=20Parent-Reservati?= =?UTF-8?q?ons=20korrekt.=20=20=20-=20Tests=20decken=20Erh=C3=B6hung,=20Se?= =?UTF-8?q?nkung=20und=20Ablehnung=20unterhalb=20bestehender=20Usage=20ab.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michael Kaufmann --- .../seeders/PlansAndResourcesTableSeeder.php | 52 ++- .../Testing/PlansAndResourcesTableSeeder.php | 15 +- .../Api/Plan/PlanResourceController.php | 8 +- .../Api/Tenant/EnvironmentController.php | 4 +- .../Tenant/Plan/PlanResourceController.php | 8 +- .../Http/Controllers/Api/TenantController.php | 17 +- .../Http/Controllers/Api/UserController.php | 9 +- packages/core/src/Support/PlanAssignments.php | 363 +++++++++++++++++- .../Feature/PlanResourceAuthorizationTest.php | 62 +++ .../TenantEnvironmentAuthorizationTest.php | 28 ++ .../TenantPlanResourceAuthorizationTest.php | 108 ++++++ .../Feature/TenantUserAuthorizationTest.php | 2 +- .../tests/Feature/UserAuthorizationTest.php | 35 ++ 13 files changed, 670 insertions(+), 41 deletions(-) diff --git a/packages/core/database/seeders/PlansAndResourcesTableSeeder.php b/packages/core/database/seeders/PlansAndResourcesTableSeeder.php index a75d867..affffc7 100644 --- a/packages/core/database/seeders/PlansAndResourcesTableSeeder.php +++ b/packages/core/database/seeders/PlansAndResourcesTableSeeder.php @@ -17,15 +17,16 @@ class PlansAndResourcesTableSeeder extends Seeder /** * Seed the baseline resource catalog and global production plans. * - * Tenant plans only contain tenant-scope resources. Environment plans only contain - * environment-scope resources. Limit semantics are `0` for no access, `-1` for - * unlimited, and positive values for finite limits. + * 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 { self::seedResourceCatalog(); - self::createTenantPlan('Platform Unlimited', [ + $platformUnlimited = self::createTenantPlan('Platform Unlimited', [ 'tenants' => -1, 'environments' => -1, 'nodes' => -1, @@ -33,8 +34,9 @@ public function run(): void 'users' => -1, 'roles' => -1, ]); + self::attachEnvironmentResourceLimits($platformUnlimited, ['users' => -1]); - self::createTenantPlan('Tenant Standard', [ + $tenantStandard = self::createTenantPlan('Tenant Standard', [ 'tenants' => 0, 'environments' => 10, 'nodes' => 0, @@ -42,8 +44,9 @@ public function run(): void 'users' => 25, 'roles' => 10, ]); + self::attachEnvironmentResourceLimits($tenantStandard, ['users' => 10]); - self::createTenantPlan('Tenant Starter', [ + $tenantStarter = self::createTenantPlan('Tenant Starter', [ 'tenants' => 0, 'environments' => 1, 'nodes' => 0, @@ -51,6 +54,7 @@ public function run(): void 'users' => 3, 'roles' => 3, ]); + self::attachEnvironmentResourceLimits($tenantStarter, ['users' => 2]); self::createEnvironmentPlan('Environment Unlimited', [ 'users' => -1, @@ -75,7 +79,7 @@ public static function seedResourceCatalog(): void } /** - * Create or update a global or tenant-owned tenant-scope plan. + * Create or update a global or tenant-owned plan from tenant-usage resources. * * @param array $limits Resource key to limit map. */ @@ -85,7 +89,7 @@ public static function createTenantPlan(string $name, array $limits, ?string $te } /** - * Create or update a global or tenant-owned environment-scope plan. + * Create or update a global or tenant-owned plan from environment-usage resources. * * @param array $limits Resource key to limit map. */ @@ -95,7 +99,21 @@ public static function createEnvironmentPlan(string $name, array $limits, ?strin } /** - * Create a plan and attach resource limits matching the plan scope. + * Attach environment-usage resource limits to an existing plan. + * + * 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 attachEnvironmentResourceLimits(Plan $plan, array $limits): Plan + { + 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. */ @@ -109,6 +127,20 @@ private static function createPlanWithResourceLimits(string $name, array $limits 'description' => null, ]); + 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(), @@ -117,7 +149,7 @@ private static function createPlanWithResourceLimits(string $name, array $limits foreach ($limits as $key => $limit) { if (!isset($resources[$key])) { - throw new \InvalidArgumentException('Unknown resource key "' . $key . '" for plan "' . $name . '".'); + throw new \InvalidArgumentException('Unknown resource key "' . $key . '" for plan "' . $plan->name . '".'); } $plan->resources()->syncWithoutDetaching([ diff --git a/packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php b/packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php index 216d84c..e2ace02 100644 --- a/packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php +++ b/packages/core/database/seeders/Testing/PlansAndResourcesTableSeeder.php @@ -16,7 +16,7 @@ class PlansAndResourcesTableSeeder extends Seeder */ public function run(): void { - BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Unlimited', [ + $testTenantUnlimited = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Unlimited', [ 'tenants' => -1, 'environments' => -1, 'nodes' => -1, @@ -24,8 +24,9 @@ public function run(): void 'users' => -1, 'roles' => -1, ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantUnlimited, ['users' => -1]); - BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Limited', [ + $testTenantLimited = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Limited', [ 'tenants' => 2, 'environments' => 2, 'nodes' => 2, @@ -33,8 +34,9 @@ public function run(): void 'users' => 2, 'roles' => 2, ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantLimited, ['users' => 2]); - BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Minimal', [ + $testTenantMinimal = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Minimal', [ 'tenants' => 0, 'environments' => 1, 'nodes' => 0, @@ -42,8 +44,9 @@ public function run(): void 'users' => 1, 'roles' => 1, ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantMinimal, ['users' => 1]); - BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Delegation Parent', [ + $testTenantDelegationParent = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Delegation Parent', [ 'tenants' => 0, 'environments' => 5, 'nodes' => 1, @@ -51,8 +54,9 @@ public function run(): void 'users' => 5, 'roles' => 5, ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantDelegationParent, ['users' => 5]); - BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Delegation Child', [ + $testTenantDelegationChild = BasePlansAndResourcesTableSeeder::createTenantPlan('Test Tenant Delegation Child', [ 'tenants' => 0, 'environments' => 2, 'nodes' => 0, @@ -60,6 +64,7 @@ public function run(): void 'users' => 2, 'roles' => 2, ]); + BasePlansAndResourcesTableSeeder::attachEnvironmentResourceLimits($testTenantDelegationChild, ['users' => 2]); BasePlansAndResourcesTableSeeder::createEnvironmentPlan('Test Environment Unlimited', [ 'users' => -1, diff --git a/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php index 67549bd..61a7d59 100644 --- a/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php +++ b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php @@ -63,11 +63,7 @@ public function store(Request $request, Plan $plan) ]); $resource = Resource::query()->findOrFail($data['resource_id']); - PlanAssignments::ensureResourceCanBeAttached($plan, $resource, (int)$data['limit']); - - $plan->resources()->syncWithoutDetaching([ - $resource->id => ['limit' => (int)$data['limit']], - ]); + PlanAssignments::updatePlanResourceLimit($plan, $resource, (int)$data['limit']); Audit::log('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $plan->tenant, context: [ 'plan_id' => $plan->id, @@ -93,7 +89,7 @@ public function destroy(Plan $plan, Resource $resource) ]); } - $plan->resources()->detach($resource); + PlanAssignments::removePlanResource($plan, $resource); Audit::log('resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', $plan->tenant, context: [ 'plan_id' => $plan->id, diff --git a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php index d99be27..b68b53b 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php @@ -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); - PlanAssignments::ensurePlanAvailableForTenant($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,7 @@ public function update(UpdateEnvironmentRequest $request, Tenant $tenant, Enviro $envData = $request->validated(); $nodeId = $this->getNonModelRequestData('node_id', $envData); - PlanAssignments::ensurePlanAvailableForTenant($envData['plan_id'] ?? null, $tenant); + PlanAssignments::ensureAssignableToEnvironment($envData['plan_id'] ?? null, $tenant); $environment->update($envData); event(new ResourceUpdated($environment, $this->validatedEventData($request))); diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php index 502b49a..762a6ce 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php @@ -64,11 +64,7 @@ public function store(Request $request, Tenant $tenant, Plan $plan) ]); $resource = Resource::query()->findOrFail($data['resource_id']); - PlanAssignments::ensureResourceCanBeAttached($plan, $resource, (int)$data['limit'], $tenant); - - $plan->resources()->syncWithoutDetaching([ - $resource->id => ['limit' => (int)$data['limit']], - ]); + PlanAssignments::updatePlanResourceLimit($plan, $resource, (int)$data['limit'], $tenant); Audit::log('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $tenant, context: [ 'plan_id' => $plan->id, @@ -94,7 +90,7 @@ public function destroy(Tenant $tenant, Plan $plan, Resource $resource) ]); } - $plan->resources()->detach($resource); + PlanAssignments::removePlanResource($plan, $resource); Audit::log('resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', $tenant, context: [ 'plan_id' => $plan->id, diff --git a/packages/core/src/Http/Controllers/Api/TenantController.php b/packages/core/src/Http/Controllers/Api/TenantController.php index 5c302fa..a7dbc54 100644 --- a/packages/core/src/Http/Controllers/Api/TenantController.php +++ b/packages/core/src/Http/Controllers/Api/TenantController.php @@ -44,9 +44,11 @@ public function store(StoreTenantRequest $request) Gate::authorize('create', [Tenant::class, $parentTenant]); $plan = Plan::query()->findOrFail($tenantData['plan_id']); - PlanAssignments::ensureAssignableToChildTenant($plan, $parentTenant); $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); @@ -97,11 +99,16 @@ public function update(UpdateTenantRequest $request, Tenant $tenant) : $tenant->plan; $oldParentTenant = $tenant->parentTenant; - if ($parentTenant !== null) { - PlanAssignments::ensureAssignableToChildTenant($plan, $parentTenant, $tenant); - } - 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); } diff --git a/packages/core/src/Http/Controllers/Api/UserController.php b/packages/core/src/Http/Controllers/Api/UserController.php index 182a673..1ab9a5e 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,12 @@ 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); + } + PlanAssignments::ensureAssignableToTenantUser($planId, $targetTenant); } $user->update($userData); diff --git a/packages/core/src/Support/PlanAssignments.php b/packages/core/src/Support/PlanAssignments.php index 1ff8873..e6af35e 100644 --- a/packages/core/src/Support/PlanAssignments.php +++ b/packages/core/src/Support/PlanAssignments.php @@ -63,7 +63,7 @@ public static function ensureAssignableToEnvironmentUser( } self::ensureHasEnabledResources($plan, $field, 'The selected plan does not contain enabled resources.'); - self::ensureWithinParentPlan($plan, $environment->plan, $field); + self::ensureWithinParentPlan($plan, self::environmentParentPlan($environment), $field); } /** @@ -95,6 +95,50 @@ public static function ensureNotAssigned(Plan $plan): void } } + /** + * 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. * @@ -224,6 +268,70 @@ public static function ensurePlanAvailableForTenant(?string $planId, Tenant $ten } } + /** + * 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'): void + { + if ($planId === null) { + 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); + } + + /** + * 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. * @@ -253,6 +361,20 @@ public static function syncTenantReservations(Tenant $parentTenant, Tenant $chil } } + /** + * 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. */ @@ -327,6 +449,173 @@ private static function ensureWithinAvailableTenantBudget(Plan $plan, Tenant $pa } } + /** + * 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. * @@ -370,22 +659,88 @@ private static function resourceIdentifier(string $key, string $type): string return $type . ':' . $key; } - private static function usageForTenant(Tenant $tenant, string $resourceKey, string $resourceType): int + private static function usageForTenant(Tenant $tenant, string $resourceKey, string $resourceType, ?string $userId = null): int { if ($resourceType === 'environment') { - return DB::table('env_usage') + 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 DB::table('tenant_usage') + 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([ diff --git a/packages/core/tests/Feature/PlanResourceAuthorizationTest.php b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php index fd8925f..c3e1449 100644 --- a/packages/core/tests/Feature/PlanResourceAuthorizationTest.php +++ b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php @@ -5,6 +5,7 @@ use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Models\TenantUsage; use Froxlor\Core\Models\User; use Tests\TestCase; @@ -123,4 +124,65 @@ public function test_detaching_unassigned_global_plan_resource_returns_validatio ->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/TenantEnvironmentAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php index e8c5f90..96c1cb5 100644 --- a/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php @@ -5,6 +5,7 @@ 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 Tests\Fakes\FakeNodeAdapter; @@ -210,14 +211,17 @@ public function test_tenant_admin_can_assign_available_environment_plan_on_creat { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); + $resource = Resource::query()->where('type', 'environment')->where('key', 'users')->firstOrFail(); $tenantPlan = Plan::query()->create([ 'tenant_id' => $tenant->id, 'name' => 'Available Environment Plan ' . str()->ulid(), ]); + $tenantPlan->resources()->attach($resource, ['limit' => 1]); $globalPlan = Plan::query()->create([ 'tenant_id' => null, 'name' => 'Global Available Environment Plan ' . str()->ulid(), ]); + $globalPlan->resources()->attach($resource, ['limit' => 1]); $environmentId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/' . $tenant->id . '/environments', [ @@ -235,4 +239,28 @@ 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()->where('type', 'environment')->where('key', 'users')->firstOrFail(); + $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']); + } } diff --git a/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php b/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php index de6a0c3..0185d77 100644 --- a/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantPlanResourceAuthorizationTest.php @@ -5,6 +5,7 @@ use Froxlor\Core\Models\Plan; use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\Tenant; +use Froxlor\Core\Models\TenantUsage; use Froxlor\Core\Models\User; use Tests\TestCase; @@ -13,6 +14,7 @@ class TenantPlanResourceAuthorizationTest extends TestCase public function test_tenant_admin_can_manage_tenant_plan_resources(): void { $tenant = Tenant::query()->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, @@ -135,4 +137,110 @@ public function test_detaching_unassigned_tenant_plan_resource_returns_validatio ->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 c01af13..14fa320 100644 --- a/packages/core/tests/Feature/TenantUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantUserAuthorizationTest.php @@ -141,7 +141,7 @@ public function test_tenant_user_plan_must_stay_within_tenant_plan(): void 'email' => 'tenant-wrong-type-plan-user-' . str()->ulid() . '@froxlor.test', 'password' => 'secret-password', 'role_id' => $role->id, - 'plan_id' => Plan::query()->whereNull('tenant_id')->firstOrFail()->id, + 'plan_id' => Plan::query()->whereNull('tenant_id')->where('name', 'Platform Unlimited')->firstOrFail()->id, ]) ->assertUnprocessable() ->assertJsonValidationErrors(['plan_id']); 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']); + } } From c0482657870244ef58597a6b16ca1de965ea1997 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Tue, 23 Jun 2026 09:04:29 +0200 Subject: [PATCH 7/8] ensure adjusting resource-limits in plans and changing plans is possible and correct Signed-off-by: Michael Kaufmann --- .../Api/Tenant/Environment/UserController.php | 54 ++++++++- .../Api/Tenant/EnvironmentController.php | 4 +- .../Controllers/Api/Tenant/UserController.php | 4 +- .../Http/Controllers/Api/UserController.php | 4 +- .../UpdateEnvironmentUserRequest.php | 26 ++++ packages/core/src/Support/PlanAssignments.php | 31 ++++- .../tests/Feature/TenantAuthorizationTest.php | 114 ++++++++++++++++++ .../TenantEnvironmentAuthorizationTest.php | 84 ++++++++++++- ...TenantEnvironmentUserAuthorizationTest.php | 70 +++++++++++ .../Feature/TenantUserAuthorizationTest.php | 63 ++++++++++ 10 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/Http/Requests/Tenant/Environment/UpdateEnvironmentUserRequest.php 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 94c5351..d6a7e34 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php @@ -7,7 +7,7 @@ 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; @@ -81,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 b68b53b..0443127 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/EnvironmentController.php @@ -79,7 +79,9 @@ public function update(UpdateEnvironmentRequest $request, Tenant $tenant, Enviro $envData = $request->validated(); $nodeId = $this->getNonModelRequestData('node_id', $envData); - PlanAssignments::ensureAssignableToEnvironment($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))); diff --git a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php index 3459668..05fdc7b 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php @@ -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); - PlanAssignments::ensureAssignableToTenantUser($planId, $tenant); + if ($planProvided) { + PlanAssignments::ensureAssignableToTenantUser($planId, $tenant, 'plan_id', $user->id); + } $user->update($userData); diff --git a/packages/core/src/Http/Controllers/Api/UserController.php b/packages/core/src/Http/Controllers/Api/UserController.php index 1ab9a5e..7b9fe11 100644 --- a/packages/core/src/Http/Controllers/Api/UserController.php +++ b/packages/core/src/Http/Controllers/Api/UserController.php @@ -124,7 +124,9 @@ public function update(UpdateUserRequest $request, User $user) if ($roleId) { RoleAssignments::ensureAssignable($request->user(), $roleId, 'role_id', $targetTenant); } - PlanAssignments::ensureAssignableToTenantUser($planId, $targetTenant); + if ($planProvided) { + PlanAssignments::ensureAssignableToTenantUser($planId, $targetTenant, 'plan_id', $user->id); + } } $user->update($userData); 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/Support/PlanAssignments.php b/packages/core/src/Support/PlanAssignments.php index e6af35e..e7efeb0 100644 --- a/packages/core/src/Support/PlanAssignments.php +++ b/packages/core/src/Support/PlanAssignments.php @@ -21,9 +21,12 @@ class PlanAssignments * * @throws ValidationException */ - public static function ensureAssignableToTenantUser(?string $planId, Tenant $tenant, string $field = 'plan_id'): void + public static function ensureAssignableToTenantUser(?string $planId, Tenant $tenant, string $field = 'plan_id', ?string $userId = null): void { if (empty($planId)) { + if ($userId !== null && $tenant->plan !== null) { + self::ensureTenantUserUsageWithinPlan($tenant, $userId, $tenant->plan, $field); + } return; } @@ -35,6 +38,10 @@ public static function ensureAssignableToTenantUser(?string $planId, Tenant $ten 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); + } } /** @@ -51,8 +58,13 @@ public static function ensureAssignableToEnvironmentUser( 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; } @@ -64,6 +76,10 @@ public static function ensureAssignableToEnvironmentUser( 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); + } } /** @@ -248,6 +264,10 @@ public static function ensureAssignableToChildTenant(Plan $plan, Tenant $parentT self::ensureWithinParentPlan($plan->loadMissing('resources'), $parentTenant->plan, $field); self::ensureWithinAvailableTenantBudget($plan, $parentTenant, $childTenant, $field); + + if ($childTenant !== null) { + self::ensureTenantUsageWithinPlan($childTenant, $plan, $field); + } } /** @@ -277,9 +297,12 @@ public static function ensurePlanAvailableForTenant(?string $planId, Tenant $ten * * @throws ValidationException */ - public static function ensureAssignableToEnvironment(?string $planId, Tenant $tenant, string $field = 'plan_id'): void + 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; } @@ -291,6 +314,10 @@ public static function ensureAssignableToEnvironment(?string $planId, Tenant $te 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); + } } /** diff --git a/packages/core/tests/Feature/TenantAuthorizationTest.php b/packages/core/tests/Feature/TenantAuthorizationTest.php index ef75265..d0e6bc0 100644 --- a/packages/core/tests/Feature/TenantAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantAuthorizationTest.php @@ -7,6 +7,7 @@ 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 @@ -210,4 +211,117 @@ public function test_child_tenant_creation_requires_parent_owned_plan(): void ->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 96c1cb5..1968b9a 100644 --- a/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentAuthorizationTest.php @@ -8,6 +8,7 @@ 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; @@ -211,7 +212,15 @@ public function test_tenant_admin_can_assign_available_environment_plan_on_creat { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); $user = User::query()->where('email', 'dev2@froxlor.org')->firstOrFail(); - $resource = Resource::query()->where('type', 'environment')->where('key', 'users')->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, 'name' => 'Available Environment Plan ' . str()->ulid(), @@ -245,7 +254,12 @@ public function test_tenant_admin_cannot_assign_environment_plan_above_tenant_pl $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()->where('type', 'environment')->where('key', 'users')->firstOrFail(); + $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], ]); @@ -263,4 +277,70 @@ public function test_tenant_admin_cannot_assign_environment_plan_above_tenant_pl ->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/TenantEnvironmentUserAuthorizationTest.php b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php index 2aea704..3d586f1 100644 --- a/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantEnvironmentUserAuthorizationTest.php @@ -8,6 +8,7 @@ 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 @@ -196,4 +197,73 @@ public function test_environment_user_plan_must_stay_within_environment_plan(): ->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/TenantUserAuthorizationTest.php b/packages/core/tests/Feature/TenantUserAuthorizationTest.php index 14fa320..a056316 100644 --- a/packages/core/tests/Feature/TenantUserAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantUserAuthorizationTest.php @@ -7,6 +7,7 @@ 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 @@ -147,6 +148,68 @@ public function test_tenant_user_plan_must_stay_within_tenant_plan(): void ->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(); From 29d9a5e6fd2b32b33e85aae5ec941aad95c82869 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Tue, 23 Jun 2026 12:12:26 +0200 Subject: [PATCH 8/8] - Remove not-yet-worked on packages to concentrate on core - Introduce Audit severity based log-methods according to RFC 5424 Signed-off-by: Michael Kaufmann --- composer.json | 26 +- .../core/database/seeders/DatabaseSeeder.php | 4 +- .../database/seeders/SettingsTableSeeder.php | 1 + .../seeders/Testing/DatabaseSeeder.php | 3 + .../src/Events/Node/NodeExploreUpdate.php | 2 +- .../core/src/Events/Node/NodeExplored.php | 2 +- .../Api/Plan/PlanResourceController.php | 4 +- .../Api/Role/RolePermissionController.php | 4 +- .../Api/Tenant/Environment/UserController.php | 2 +- .../Tenant/Plan/PlanResourceController.php | 4 +- .../Tenant/Role/RolePermissionController.php | 4 +- .../Controllers/Api/Tenant/UserController.php | 2 +- .../Jobs/Environment/CreateEnvironment.php | 2 +- .../Jobs/Environment/DeleteEnvironment.php | 2 +- .../src/Observers/EnvironmentObserver.php | 6 +- packages/core/src/Observers/NodeObserver.php | 6 +- .../src/Providers/EventServiceProvider.php | 2 +- .../Overview/Schemas/OverviewSchema.php | 2 +- .../core/src/Resources/Plans/PlanResource.php | 2 +- .../{Tables => Schemas}/ResourceTable.php | 2 +- .../src/Resources/Plans/Schemas/PlanForm.php | 31 +- .../src/Resources/Plans/Schemas/PlanView.php | 2 +- packages/core/src/Support/Audit.php | 49 ++- packages/core/src/Support/Setting.php | 1 + .../tests/Feature/PermissionRegistryTest.php | 8 - .../tests/Feature/ResourceRegistryTest.php | 27 +- packages/database/.gitignore | 4 - packages/database/composer.json | 47 --- ...1_000001_create_database_servers_table.php | 45 --- ...02_01_01_000002_create_databases_table.php | 41 --- .../configure/mariadb/debian13.blade.php | 16 - .../configure/mariadb/ubuntu2404.blade.php | 16 - .../configure/mysql/debian13.blade.php | 16 - .../configure/mysql/ubuntu2404.blade.php | 16 - .../configure/pgsql/debian13.blade.php | 9 - .../configure/pgsql/ubuntu2404.blade.php | 9 - .../install/mariadb/debian13.blade.php | 9 - .../install/mariadb/ubuntu2404.blade.php | 9 - .../install/mysql/debian13.blade.php | 9 - .../install/mysql/ubuntu2404.blade.php | 9 - .../install/pgsql/debian13.blade.php | 9 - .../install/pgsql/ubuntu2404.blade.php | 9 - packages/database/routes/api.php | 27 -- packages/database/routes/web.php | 27 -- .../src/Events/Database/DatabaseCreated.php | 18 -- .../src/Events/Database/DatabaseDeleted.php | 18 -- .../Events/Database/DatabaseForceDeleted.php | 18 -- .../src/Events/Database/DatabaseRestored.php | 18 -- .../src/Events/Database/DatabaseUpdated.php | 18 -- .../DatabaseServer/DatabaseServerCreated.php | 18 -- .../DatabaseServer/DatabaseServerDeleted.php | 18 -- .../DatabaseServerForceDeleted.php | 18 -- .../DatabaseServer/DatabaseServerRestored.php | 18 -- .../DatabaseServer/DatabaseServerUpdated.php | 18 -- .../Api/Node/DatabaseServiceController.php | 109 ------- .../Tenant/Environment/DatabaseController.php | 106 ------- .../src/Http/Controllers/Controller.php | 8 - .../Web/Node/DatabaseServiceController.php | 80 ----- .../Tenant/Environment/DatabaseController.php | 34 --- .../Http/Requests/StoreDatabaseRequest.php | 27 -- .../Requests/StoreDatabaseServerRequest.php | 27 -- .../Http/Requests/UpdateDatabaseRequest.php | 28 -- .../Requests/UpdateDatabaseServerRequest.php | 27 -- .../DatabaseService/CheckDatabaseService.php | 22 -- .../ConfigureDatabaseService.php | 22 -- .../InstallDatabaseService.php | 22 -- .../src/Listeners/Database/CreateDatabase.php | 17 -- .../DatabaseServer/CreateDatabaseServer.php | 17 -- packages/database/src/Models/Database.php | 64 ---- .../database/src/Models/DatabaseServer.php | 65 ---- .../src/Observers/DatabaseObserver.php | 34 --- .../src/Observers/DatabaseServerObserver.php | 34 --- .../database/src/Policies/DatabasePolicy.php | 44 --- .../src/Providers/EventServiceProvider.php | 15 - .../FroxlorDatabaseServiceProvider.php | 147 --------- .../Nodes/DatabaseServiceResource.php | 69 ----- .../Schemas/DatabaseServerForm.php | 54 ---- .../Schemas/DatabaseServerView.php | 48 --- .../Relations/Databases/DatabaseResource.php | 112 ------- .../Databases/Schemas/DatabaseForm.php | 67 ----- .../Databases/Schemas/DatabaseView.php | 63 ---- .../Databases/Tables/DatabaseTable.php | 69 ----- .../src/Services/DatabaseServiceLifecycle.php | 208 ------------- packages/developer/.gitignore | 4 - packages/developer/composer.json | 46 --- .../views/components/base-layout.blade.php | 13 - .../views/docs/components/accordion.blade.php | 75 ----- .../views/docs/components/alert.blade.php | 127 -------- .../views/docs/components/avatar.blade.php | 39 --- .../views/docs/components/badge.blade.php | 56 ---- .../views/docs/components/body.blade.php | 27 -- .../docs/components/breadcrumb.blade.php | 57 ---- .../docs/components/button-group.blade.php | 49 --- .../views/docs/components/button.blade.php | 58 ---- .../views/docs/components/card.blade.php | 104 ------- .../views/docs/components/chart.blade.php | 24 -- .../views/docs/components/checkbox.blade.php | 65 ---- .../views/docs/components/code.blade.php | 46 --- .../docs/components/collapsible.blade.php | 39 --- .../docs/components/date-picker.blade.php | 83 ------ .../components/description-list.blade.php | 24 -- .../views/docs/components/dialog.blade.php | 73 ----- .../views/docs/components/dropdown.blade.php | 132 --------- .../views/docs/components/field.blade.php | 42 --- .../views/docs/components/flex.blade.php | 24 -- .../views/docs/components/form.blade.php | 86 ------ .../views/docs/components/grid.blade.php | 24 -- .../views/docs/components/heading.blade.php | 39 --- .../views/docs/components/icon.blade.php | 91 ------ .../docs/components/input-group.blade.php | 24 -- .../views/docs/components/input.blade.php | 32 -- .../views/docs/components/item.blade.php | 24 -- .../views/docs/components/kbd.blade.php | 24 -- .../views/docs/components/label.blade.php | 32 -- .../views/docs/components/lead.blade.php | 23 -- .../views/docs/components/link.blade.php | 23 -- .../views/docs/components/logo.blade.php | 23 -- .../views/docs/components/main.blade.php | 29 -- .../views/docs/components/middle.blade.php | 29 -- .../views/docs/components/navbar.blade.php | 82 ----- .../docs/components/navigation-menu.blade.php | 11 - .../docs/components/number-field.blade.php | 24 -- .../docs/components/pagination.blade.php | 44 --- .../docs/components/placeholder.blade.php | 23 -- .../views/docs/components/popover.blade.php | 65 ---- .../docs/components/progress-group.blade.php | 31 -- .../views/docs/components/progress.blade.php | 43 --- .../docs/components/radio-group.blade.php | 24 -- .../views/docs/components/section.blade.php | 24 -- .../views/docs/components/select.blade.php | 32 -- .../views/docs/components/separator.blade.php | 24 -- .../views/docs/components/sidebar.blade.php | 112 ------- .../views/docs/components/skeleton.blade.php | 115 ------- .../views/docs/components/space.blade.php | 24 -- .../views/docs/components/spinner.blade.php | 33 --- .../views/docs/components/stepper.blade.php | 24 -- .../views/docs/components/subtitle.blade.php | 23 -- .../views/docs/components/table.blade.php | 24 -- .../views/docs/components/tabs.blade.php | 69 ----- .../views/docs/components/teaser.blade.php | 23 -- .../views/docs/components/text.blade.php | 23 -- .../views/docs/components/textarea.blade.php | 32 -- .../views/docs/components/title.blade.php | 27 -- .../views/docs/components/toast.blade.php | 40 --- .../views/docs/components/toggle.blade.php | 48 --- .../views/docs/components/tooltip.blade.php | 47 --- .../views/docs/forms/components.blade.php | 151 ---------- .../views/docs/forms/overview.blade.php | 75 ----- .../views/docs/forms/quick-start.blade.php | 129 -------- .../views/docs/layouts/app-layout.blade.php | 37 --- .../views/docs/layouts/auth-layout.blade.php | 31 -- .../views/docs/layouts/guest-layout.blade.php | 27 -- .../docs/layouts/sidebar-layout.blade.php | 31 -- .../docs/layouts/stacked-layout.blade.php | 31 -- .../views/docs/navigations/navbar.blade.php | 27 -- .../views/docs/navigations/sidebar.blade.php | 27 -- .../views/docs/pages/components.blade.php | 130 -------- .../views/docs/pages/overview.blade.php | 65 ---- .../views/docs/pages/quick-start.blade.php | 99 ------- .../views/docs/schema/overview.blade.php | 46 --- .../views/docs/tables/components.blade.php | 55 ---- .../views/docs/tables/quick-start.blade.php | 280 ------------------ .../views/docs/utilities/schema.blade.php | 9 - .../developer/resources/views/index.blade.php | 67 ----- packages/developer/routes/web.php | 12 - .../src/Http/Controllers/Controller.php | 8 - .../Controllers/Web/DeveloperController.php | 19 -- .../FroxlorDeveloperServiceProvider.php | 152 ---------- packages/domain/.gitignore | 4 - packages/domain/composer.json | 54 ---- ...0001_01_03_000001_create_domains_table.php | 33 --- .../database/seeders/DatabaseSeeder.php | 53 ---- .../seeders/Testing/DomainTableSeeder.php | 45 --- packages/domain/lang/en/generic.php | 7 - packages/domain/routes/api.php | 9 - packages/domain/routes/web.php | 12 - .../Http/Controllers/Api/DomainController.php | 71 ----- .../Api/Tenant/DomainController.php | 77 ----- .../Tenant/Environment/DomainController.php | 79 ----- .../Http/Controllers/Web/DomainController.php | 31 -- .../Web/Tenant/DomainController.php | 32 -- .../Tenant/Environment/DomainController.php | 33 --- .../src/Http/Requests/StoreDomainRequest.php | 39 --- .../StoreTenantEnvironmentDomainRequest.php | 37 --- .../Tenant/StoreTenantDomainRequest.php | 38 --- .../src/Http/Requests/UpdateDomainRequest.php | 38 --- .../domain/src/Listeners/SeedDatabase.php | 14 - packages/domain/src/Models/Domain.php | 55 ---- .../src/Providers/EventServiceProvider.php | 21 -- .../FroxlorDomainServiceProvider.php | 107 ------- .../domain/src/Resources/DomainResource.php | 109 ------- .../Resources/EnvironmentDomainResource.php | 117 -------- .../src/Resources/Schemas/DomainSchema.php | 65 ---- .../src/Resources/TenantDomainResource.php | 94 ------ packages/ftp/.gitignore | 4 - packages/ftp/composer.json | 46 --- ...01_01_000001_create_ftp_services_table.php | 41 --- .../configure/vsftpd/debian13.blade.php | 16 - .../configure/vsftpd/ubuntu2404.blade.php | 16 - .../install/vsftpd/debian13.blade.php | 9 - .../install/vsftpd/ubuntu2404.blade.php | 9 - packages/ftp/routes/api.php | 23 -- packages/ftp/routes/web.php | 22 -- .../Api/Node/FtpServiceController.php | 108 ------- .../ftp/src/Http/Controllers/Controller.php | 8 - .../Web/Node/FtpServiceController.php | 80 ----- .../Http/Requests/StoreFtpServiceRequest.php | 29 -- .../Http/Requests/UpdateFtpServiceRequest.php | 29 -- .../src/Jobs/FtpService/CheckFtpService.php | 22 -- .../Jobs/FtpService/ConfigureFtpService.php | 22 -- .../src/Jobs/FtpService/InstallFtpService.php | 22 -- packages/ftp/src/Models/FtpService.php | 59 ---- .../Providers/FroxlorFtpServiceProvider.php | 101 ------- .../Resources/Nodes/FtpServiceResource.php | 69 ----- .../FtpServices/Schemas/FtpServiceForm.php | 70 ----- .../FtpServices/Schemas/FtpServiceView.php | 45 --- .../ftp/src/Services/FtpServiceLifecycle.php | 209 ------------- packages/mail/.gitignore | 4 - packages/mail/composer.json | 47 --- ..._05_000001_create_mail_addresses_table.php | 38 --- ...1_05_000002_create_mail_accounts_table.php | 38 --- .../mail/database/seeders/DatabaseSeeder.php | 53 ---- .../seeders/Testing/MailTableSeeder.php | 69 ----- packages/mail/routes/api.php | 8 - packages/mail/routes/web.php | 7 - .../Environment/MailAccountController.php | 71 ----- .../Api/Tenant/Environment/MailController.php | 86 ------ .../Http/Requests/StoreMailAccountRequest.php | 39 --- .../Http/Requests/StoreMailAddressRequest.php | 58 ---- .../Requests/UpdateMailAccountRequest.php | 38 --- .../Requests/UpdateMailAddressRequest.php | 58 ---- .../mail/src/Jobs/SendMailToNewAccountJob.php | 20 -- .../Listeners/ExtendCollectionResponse.php | 24 -- .../src/Listeners/ExtendResourceResponse.php | 22 -- packages/mail/src/Listeners/SeedDatabase.php | 14 - packages/mail/src/Models/MailAccount.php | 53 ---- packages/mail/src/Models/MailAddress.php | 51 ---- .../src/Providers/EventServiceProvider.php | 27 -- .../Providers/FroxlorMailServiceProvider.php | 69 ----- .../mail/src/Resources/Schemas/MailSchema.php | 58 ---- packages/mail/src/Services/CreateAccount.php | 39 --- packages/mail/src/Services/CreateMail.php | 43 --- packages/mail/src/Services/UpdateAccount.php | 26 -- packages/mail/src/Services/UpdateMail.php | 27 -- .../database/seeders/DatabaseSeeder.php | 4 +- packages/packages/src/Models/Repository.php | 1 - packages/web/.gitignore | 4 - packages/web/composer.json | 47 --- ...01_04_000001_create_domain_vhost_table.php | 40 --- ...4_000002_create_domain_ssl_vhost_table.php | 45 --- ...te_domain_vhosts_node_interfaces_table.php | 27 -- .../web/database/seeders/DatabaseSeeder.php | 51 ---- .../seeders/Testing/WebTableSeeder.php | 70 ----- .../configure/apache/debian13.blade.php | 1 - .../configure/apache/ubuntu2404.blade.php | 1 - .../configure/nginx/debian13.blade.php | 1 - .../configure/nginx/ubuntu2404.blade.php | 1 - .../templates/apache/domain_vhost.blade.php | 42 --- .../templates/nginx/domain_vhost.blade.php | 28 -- packages/web/routes/api.php | 5 - packages/web/routes/web.php | 7 - .../web/src/Console/Commands/TestVhost.php | 43 --- packages/web/src/Enums/HstsMode.php | 11 - packages/web/src/Enums/SslMode.php | 10 - .../Http/Requests/StoreDomainVhostRequest.php | 70 ----- .../web/src/Jobs/GenerateDomainVhostJob.php | 59 ---- packages/web/src/Listeners/CreateDomain.php | 21 -- .../Listeners/ExtendCollectionResponse.php | 23 -- .../src/Listeners/ExtendResourceResponse.php | 26 -- .../Listeners/ExtendResourceValidation.php | 24 -- packages/web/src/Listeners/SeedDatabase.php | 14 - packages/web/src/Models/DomainSslVhost.php | 44 --- packages/web/src/Models/DomainVhost.php | 67 ----- .../src/Models/DomainVhostsNodeInterfaces.php | 39 --- .../src/Providers/EventServiceProvider.php | 34 --- .../Providers/FroxlorWebServiceProvider.php | 143 --------- .../web/src/Services/DomainVhostService.php | 39 --- packages/web/src/Services/SslService.php | 13 - 278 files changed, 92 insertions(+), 11038 deletions(-) rename packages/core/src/Resources/Plans/Relations/Resources/{Tables => Schemas}/ResourceTable.php (89%) delete mode 100644 packages/database/.gitignore delete mode 100644 packages/database/composer.json delete mode 100644 packages/database/database/migrations/0002_01_01_000001_create_database_servers_table.php delete mode 100644 packages/database/database/migrations/0002_01_01_000002_create_databases_table.php delete mode 100644 packages/database/resources/views/scripts/database-service/configure/mariadb/debian13.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/configure/mariadb/ubuntu2404.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/configure/mysql/debian13.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/configure/mysql/ubuntu2404.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/configure/pgsql/debian13.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/configure/pgsql/ubuntu2404.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/install/mariadb/debian13.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/install/mariadb/ubuntu2404.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/install/mysql/debian13.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/install/mysql/ubuntu2404.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/install/pgsql/debian13.blade.php delete mode 100644 packages/database/resources/views/scripts/database-service/install/pgsql/ubuntu2404.blade.php delete mode 100644 packages/database/routes/api.php delete mode 100644 packages/database/routes/web.php delete mode 100644 packages/database/src/Events/Database/DatabaseCreated.php delete mode 100644 packages/database/src/Events/Database/DatabaseDeleted.php delete mode 100644 packages/database/src/Events/Database/DatabaseForceDeleted.php delete mode 100644 packages/database/src/Events/Database/DatabaseRestored.php delete mode 100644 packages/database/src/Events/Database/DatabaseUpdated.php delete mode 100644 packages/database/src/Events/DatabaseServer/DatabaseServerCreated.php delete mode 100644 packages/database/src/Events/DatabaseServer/DatabaseServerDeleted.php delete mode 100644 packages/database/src/Events/DatabaseServer/DatabaseServerForceDeleted.php delete mode 100644 packages/database/src/Events/DatabaseServer/DatabaseServerRestored.php delete mode 100644 packages/database/src/Events/DatabaseServer/DatabaseServerUpdated.php delete mode 100644 packages/database/src/Http/Controllers/Api/Node/DatabaseServiceController.php delete mode 100644 packages/database/src/Http/Controllers/Api/Tenant/Environment/DatabaseController.php delete mode 100644 packages/database/src/Http/Controllers/Controller.php delete mode 100644 packages/database/src/Http/Controllers/Web/Node/DatabaseServiceController.php delete mode 100644 packages/database/src/Http/Controllers/Web/Tenant/Environment/DatabaseController.php delete mode 100644 packages/database/src/Http/Requests/StoreDatabaseRequest.php delete mode 100644 packages/database/src/Http/Requests/StoreDatabaseServerRequest.php delete mode 100644 packages/database/src/Http/Requests/UpdateDatabaseRequest.php delete mode 100644 packages/database/src/Http/Requests/UpdateDatabaseServerRequest.php delete mode 100644 packages/database/src/Jobs/DatabaseService/CheckDatabaseService.php delete mode 100644 packages/database/src/Jobs/DatabaseService/ConfigureDatabaseService.php delete mode 100644 packages/database/src/Jobs/DatabaseService/InstallDatabaseService.php delete mode 100644 packages/database/src/Listeners/Database/CreateDatabase.php delete mode 100644 packages/database/src/Listeners/DatabaseServer/CreateDatabaseServer.php delete mode 100644 packages/database/src/Models/Database.php delete mode 100644 packages/database/src/Models/DatabaseServer.php delete mode 100644 packages/database/src/Observers/DatabaseObserver.php delete mode 100644 packages/database/src/Observers/DatabaseServerObserver.php delete mode 100644 packages/database/src/Policies/DatabasePolicy.php delete mode 100644 packages/database/src/Providers/EventServiceProvider.php delete mode 100644 packages/database/src/Providers/FroxlorDatabaseServiceProvider.php delete mode 100644 packages/database/src/Resources/Nodes/DatabaseServiceResource.php delete mode 100644 packages/database/src/Resources/Nodes/Relations/DatabaseServers/Schemas/DatabaseServerForm.php delete mode 100644 packages/database/src/Resources/Nodes/Relations/DatabaseServers/Schemas/DatabaseServerView.php delete mode 100644 packages/database/src/Resources/Tenants/Relations/Databases/DatabaseResource.php delete mode 100644 packages/database/src/Resources/Tenants/Relations/Databases/Schemas/DatabaseForm.php delete mode 100644 packages/database/src/Resources/Tenants/Relations/Databases/Schemas/DatabaseView.php delete mode 100644 packages/database/src/Resources/Tenants/Relations/Databases/Tables/DatabaseTable.php delete mode 100644 packages/database/src/Services/DatabaseServiceLifecycle.php delete mode 100644 packages/developer/.gitignore delete mode 100644 packages/developer/composer.json delete mode 100644 packages/developer/resources/views/components/base-layout.blade.php delete mode 100644 packages/developer/resources/views/docs/components/accordion.blade.php delete mode 100644 packages/developer/resources/views/docs/components/alert.blade.php delete mode 100644 packages/developer/resources/views/docs/components/avatar.blade.php delete mode 100644 packages/developer/resources/views/docs/components/badge.blade.php delete mode 100644 packages/developer/resources/views/docs/components/body.blade.php delete mode 100644 packages/developer/resources/views/docs/components/breadcrumb.blade.php delete mode 100644 packages/developer/resources/views/docs/components/button-group.blade.php delete mode 100644 packages/developer/resources/views/docs/components/button.blade.php delete mode 100644 packages/developer/resources/views/docs/components/card.blade.php delete mode 100644 packages/developer/resources/views/docs/components/chart.blade.php delete mode 100644 packages/developer/resources/views/docs/components/checkbox.blade.php delete mode 100644 packages/developer/resources/views/docs/components/code.blade.php delete mode 100644 packages/developer/resources/views/docs/components/collapsible.blade.php delete mode 100644 packages/developer/resources/views/docs/components/date-picker.blade.php delete mode 100644 packages/developer/resources/views/docs/components/description-list.blade.php delete mode 100644 packages/developer/resources/views/docs/components/dialog.blade.php delete mode 100644 packages/developer/resources/views/docs/components/dropdown.blade.php delete mode 100644 packages/developer/resources/views/docs/components/field.blade.php delete mode 100644 packages/developer/resources/views/docs/components/flex.blade.php delete mode 100644 packages/developer/resources/views/docs/components/form.blade.php delete mode 100644 packages/developer/resources/views/docs/components/grid.blade.php delete mode 100644 packages/developer/resources/views/docs/components/heading.blade.php delete mode 100644 packages/developer/resources/views/docs/components/icon.blade.php delete mode 100644 packages/developer/resources/views/docs/components/input-group.blade.php delete mode 100644 packages/developer/resources/views/docs/components/input.blade.php delete mode 100644 packages/developer/resources/views/docs/components/item.blade.php delete mode 100644 packages/developer/resources/views/docs/components/kbd.blade.php delete mode 100644 packages/developer/resources/views/docs/components/label.blade.php delete mode 100644 packages/developer/resources/views/docs/components/lead.blade.php delete mode 100644 packages/developer/resources/views/docs/components/link.blade.php delete mode 100644 packages/developer/resources/views/docs/components/logo.blade.php delete mode 100644 packages/developer/resources/views/docs/components/main.blade.php delete mode 100644 packages/developer/resources/views/docs/components/middle.blade.php delete mode 100644 packages/developer/resources/views/docs/components/navbar.blade.php delete mode 100644 packages/developer/resources/views/docs/components/navigation-menu.blade.php delete mode 100644 packages/developer/resources/views/docs/components/number-field.blade.php delete mode 100644 packages/developer/resources/views/docs/components/pagination.blade.php delete mode 100644 packages/developer/resources/views/docs/components/placeholder.blade.php delete mode 100644 packages/developer/resources/views/docs/components/popover.blade.php delete mode 100644 packages/developer/resources/views/docs/components/progress-group.blade.php delete mode 100644 packages/developer/resources/views/docs/components/progress.blade.php delete mode 100644 packages/developer/resources/views/docs/components/radio-group.blade.php delete mode 100644 packages/developer/resources/views/docs/components/section.blade.php delete mode 100644 packages/developer/resources/views/docs/components/select.blade.php delete mode 100644 packages/developer/resources/views/docs/components/separator.blade.php delete mode 100644 packages/developer/resources/views/docs/components/sidebar.blade.php delete mode 100644 packages/developer/resources/views/docs/components/skeleton.blade.php delete mode 100644 packages/developer/resources/views/docs/components/space.blade.php delete mode 100644 packages/developer/resources/views/docs/components/spinner.blade.php delete mode 100644 packages/developer/resources/views/docs/components/stepper.blade.php delete mode 100644 packages/developer/resources/views/docs/components/subtitle.blade.php delete mode 100644 packages/developer/resources/views/docs/components/table.blade.php delete mode 100644 packages/developer/resources/views/docs/components/tabs.blade.php delete mode 100644 packages/developer/resources/views/docs/components/teaser.blade.php delete mode 100644 packages/developer/resources/views/docs/components/text.blade.php delete mode 100644 packages/developer/resources/views/docs/components/textarea.blade.php delete mode 100644 packages/developer/resources/views/docs/components/title.blade.php delete mode 100644 packages/developer/resources/views/docs/components/toast.blade.php delete mode 100644 packages/developer/resources/views/docs/components/toggle.blade.php delete mode 100644 packages/developer/resources/views/docs/components/tooltip.blade.php delete mode 100644 packages/developer/resources/views/docs/forms/components.blade.php delete mode 100644 packages/developer/resources/views/docs/forms/overview.blade.php delete mode 100644 packages/developer/resources/views/docs/forms/quick-start.blade.php delete mode 100644 packages/developer/resources/views/docs/layouts/app-layout.blade.php delete mode 100644 packages/developer/resources/views/docs/layouts/auth-layout.blade.php delete mode 100644 packages/developer/resources/views/docs/layouts/guest-layout.blade.php delete mode 100644 packages/developer/resources/views/docs/layouts/sidebar-layout.blade.php delete mode 100644 packages/developer/resources/views/docs/layouts/stacked-layout.blade.php delete mode 100644 packages/developer/resources/views/docs/navigations/navbar.blade.php delete mode 100644 packages/developer/resources/views/docs/navigations/sidebar.blade.php delete mode 100644 packages/developer/resources/views/docs/pages/components.blade.php delete mode 100644 packages/developer/resources/views/docs/pages/overview.blade.php delete mode 100644 packages/developer/resources/views/docs/pages/quick-start.blade.php delete mode 100644 packages/developer/resources/views/docs/schema/overview.blade.php delete mode 100644 packages/developer/resources/views/docs/tables/components.blade.php delete mode 100644 packages/developer/resources/views/docs/tables/quick-start.blade.php delete mode 100644 packages/developer/resources/views/docs/utilities/schema.blade.php delete mode 100644 packages/developer/resources/views/index.blade.php delete mode 100644 packages/developer/routes/web.php delete mode 100644 packages/developer/src/Http/Controllers/Controller.php delete mode 100644 packages/developer/src/Http/Controllers/Web/DeveloperController.php delete mode 100644 packages/developer/src/Providers/FroxlorDeveloperServiceProvider.php delete mode 100644 packages/domain/.gitignore delete mode 100644 packages/domain/composer.json delete mode 100644 packages/domain/database/migrations/0001_01_03_000001_create_domains_table.php delete mode 100644 packages/domain/database/seeders/DatabaseSeeder.php delete mode 100644 packages/domain/database/seeders/Testing/DomainTableSeeder.php delete mode 100644 packages/domain/lang/en/generic.php delete mode 100644 packages/domain/routes/api.php delete mode 100644 packages/domain/routes/web.php delete mode 100644 packages/domain/src/Http/Controllers/Api/DomainController.php delete mode 100644 packages/domain/src/Http/Controllers/Api/Tenant/DomainController.php delete mode 100644 packages/domain/src/Http/Controllers/Api/Tenant/Environment/DomainController.php delete mode 100644 packages/domain/src/Http/Controllers/Web/DomainController.php delete mode 100644 packages/domain/src/Http/Controllers/Web/Tenant/DomainController.php delete mode 100644 packages/domain/src/Http/Controllers/Web/Tenant/Environment/DomainController.php delete mode 100644 packages/domain/src/Http/Requests/StoreDomainRequest.php delete mode 100644 packages/domain/src/Http/Requests/Tenant/Environment/StoreTenantEnvironmentDomainRequest.php delete mode 100644 packages/domain/src/Http/Requests/Tenant/StoreTenantDomainRequest.php delete mode 100644 packages/domain/src/Http/Requests/UpdateDomainRequest.php delete mode 100644 packages/domain/src/Listeners/SeedDatabase.php delete mode 100644 packages/domain/src/Models/Domain.php delete mode 100644 packages/domain/src/Providers/EventServiceProvider.php delete mode 100644 packages/domain/src/Providers/FroxlorDomainServiceProvider.php delete mode 100644 packages/domain/src/Resources/DomainResource.php delete mode 100644 packages/domain/src/Resources/EnvironmentDomainResource.php delete mode 100644 packages/domain/src/Resources/Schemas/DomainSchema.php delete mode 100644 packages/domain/src/Resources/TenantDomainResource.php delete mode 100644 packages/ftp/.gitignore delete mode 100644 packages/ftp/composer.json delete mode 100644 packages/ftp/database/migrations/0002_01_01_000001_create_ftp_services_table.php delete mode 100644 packages/ftp/resources/views/scripts/ftp-service/configure/vsftpd/debian13.blade.php delete mode 100644 packages/ftp/resources/views/scripts/ftp-service/configure/vsftpd/ubuntu2404.blade.php delete mode 100644 packages/ftp/resources/views/scripts/ftp-service/install/vsftpd/debian13.blade.php delete mode 100644 packages/ftp/resources/views/scripts/ftp-service/install/vsftpd/ubuntu2404.blade.php delete mode 100644 packages/ftp/routes/api.php delete mode 100644 packages/ftp/routes/web.php delete mode 100644 packages/ftp/src/Http/Controllers/Api/Node/FtpServiceController.php delete mode 100644 packages/ftp/src/Http/Controllers/Controller.php delete mode 100644 packages/ftp/src/Http/Controllers/Web/Node/FtpServiceController.php delete mode 100644 packages/ftp/src/Http/Requests/StoreFtpServiceRequest.php delete mode 100644 packages/ftp/src/Http/Requests/UpdateFtpServiceRequest.php delete mode 100644 packages/ftp/src/Jobs/FtpService/CheckFtpService.php delete mode 100644 packages/ftp/src/Jobs/FtpService/ConfigureFtpService.php delete mode 100644 packages/ftp/src/Jobs/FtpService/InstallFtpService.php delete mode 100644 packages/ftp/src/Models/FtpService.php delete mode 100644 packages/ftp/src/Providers/FroxlorFtpServiceProvider.php delete mode 100644 packages/ftp/src/Resources/Nodes/FtpServiceResource.php delete mode 100644 packages/ftp/src/Resources/Nodes/Relations/FtpServices/Schemas/FtpServiceForm.php delete mode 100644 packages/ftp/src/Resources/Nodes/Relations/FtpServices/Schemas/FtpServiceView.php delete mode 100644 packages/ftp/src/Services/FtpServiceLifecycle.php delete mode 100644 packages/mail/.gitignore delete mode 100644 packages/mail/composer.json delete mode 100644 packages/mail/database/migrations/0001_01_05_000001_create_mail_addresses_table.php delete mode 100644 packages/mail/database/migrations/0001_01_05_000002_create_mail_accounts_table.php delete mode 100644 packages/mail/database/seeders/DatabaseSeeder.php delete mode 100644 packages/mail/database/seeders/Testing/MailTableSeeder.php delete mode 100644 packages/mail/routes/api.php delete mode 100644 packages/mail/routes/web.php delete mode 100644 packages/mail/src/Http/Controllers/Api/Tenant/Environment/MailAccountController.php delete mode 100644 packages/mail/src/Http/Controllers/Api/Tenant/Environment/MailController.php delete mode 100644 packages/mail/src/Http/Requests/StoreMailAccountRequest.php delete mode 100644 packages/mail/src/Http/Requests/StoreMailAddressRequest.php delete mode 100644 packages/mail/src/Http/Requests/UpdateMailAccountRequest.php delete mode 100644 packages/mail/src/Http/Requests/UpdateMailAddressRequest.php delete mode 100644 packages/mail/src/Jobs/SendMailToNewAccountJob.php delete mode 100644 packages/mail/src/Listeners/ExtendCollectionResponse.php delete mode 100644 packages/mail/src/Listeners/ExtendResourceResponse.php delete mode 100644 packages/mail/src/Listeners/SeedDatabase.php delete mode 100644 packages/mail/src/Models/MailAccount.php delete mode 100644 packages/mail/src/Models/MailAddress.php delete mode 100644 packages/mail/src/Providers/EventServiceProvider.php delete mode 100644 packages/mail/src/Providers/FroxlorMailServiceProvider.php delete mode 100644 packages/mail/src/Resources/Schemas/MailSchema.php delete mode 100644 packages/mail/src/Services/CreateAccount.php delete mode 100644 packages/mail/src/Services/CreateMail.php delete mode 100644 packages/mail/src/Services/UpdateAccount.php delete mode 100644 packages/mail/src/Services/UpdateMail.php delete mode 100644 packages/web/.gitignore delete mode 100644 packages/web/composer.json delete mode 100644 packages/web/database/migrations/0001_01_04_000001_create_domain_vhost_table.php delete mode 100644 packages/web/database/migrations/0001_01_04_000002_create_domain_ssl_vhost_table.php delete mode 100644 packages/web/database/migrations/0001_01_04_000010_create_domain_vhosts_node_interfaces_table.php delete mode 100644 packages/web/database/seeders/DatabaseSeeder.php delete mode 100644 packages/web/database/seeders/Testing/WebTableSeeder.php delete mode 100644 packages/web/resources/views/scripts/web-vhost/configure/apache/debian13.blade.php delete mode 100644 packages/web/resources/views/scripts/web-vhost/configure/apache/ubuntu2404.blade.php delete mode 100644 packages/web/resources/views/scripts/web-vhost/configure/nginx/debian13.blade.php delete mode 100644 packages/web/resources/views/scripts/web-vhost/configure/nginx/ubuntu2404.blade.php delete mode 100644 packages/web/resources/views/templates/apache/domain_vhost.blade.php delete mode 100644 packages/web/resources/views/templates/nginx/domain_vhost.blade.php delete mode 100644 packages/web/routes/api.php delete mode 100644 packages/web/routes/web.php delete mode 100644 packages/web/src/Console/Commands/TestVhost.php delete mode 100644 packages/web/src/Enums/HstsMode.php delete mode 100644 packages/web/src/Enums/SslMode.php delete mode 100644 packages/web/src/Http/Requests/StoreDomainVhostRequest.php delete mode 100644 packages/web/src/Jobs/GenerateDomainVhostJob.php delete mode 100644 packages/web/src/Listeners/CreateDomain.php delete mode 100644 packages/web/src/Listeners/ExtendCollectionResponse.php delete mode 100644 packages/web/src/Listeners/ExtendResourceResponse.php delete mode 100644 packages/web/src/Listeners/ExtendResourceValidation.php delete mode 100644 packages/web/src/Listeners/SeedDatabase.php delete mode 100644 packages/web/src/Models/DomainSslVhost.php delete mode 100644 packages/web/src/Models/DomainVhost.php delete mode 100644 packages/web/src/Models/DomainVhostsNodeInterfaces.php delete mode 100644 packages/web/src/Providers/EventServiceProvider.php delete mode 100644 packages/web/src/Providers/FroxlorWebServiceProvider.php delete mode 100644 packages/web/src/Services/DomainVhostService.php delete mode 100644 packages/web/src/Services/SslService.php diff --git a/composer.json b/composer.json index 03168d6..1be90f8 100644 --- a/composer.json +++ b/composer.json @@ -50,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" } }, @@ -82,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": { @@ -101,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/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/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 3b71be2..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,6 +16,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { + Setting::set('auditlog.severity', 7, 'integer', 5); + $this->call([ PlansAndResourcesTableSeeder::class, TenantAndUsersTableSeeder::class, 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 index 61a7d59..eb26691 100644 --- a/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php +++ b/packages/core/src/Http/Controllers/Api/Plan/PlanResourceController.php @@ -65,7 +65,7 @@ public function store(Request $request, Plan $plan) $resource = Resource::query()->findOrFail($data['resource_id']); PlanAssignments::updatePlanResourceLimit($plan, $resource, (int)$data['limit']); - Audit::log('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $plan->tenant, context: [ + 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, @@ -91,7 +91,7 @@ public function destroy(Plan $plan, Resource $resource) PlanAssignments::removePlanResource($plan, $resource); - Audit::log('resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', $plan->tenant, context: [ + 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, 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/UserController.php b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php index d6a7e34..4990d5c 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Environment/UserController.php @@ -61,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()); } diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php index 762a6ce..04420b5 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Plan/PlanResourceController.php @@ -66,7 +66,7 @@ public function store(Request $request, Tenant $tenant, Plan $plan) $resource = Resource::query()->findOrFail($data['resource_id']); PlanAssignments::updatePlanResourceLimit($plan, $resource, (int)$data['limit'], $tenant); - Audit::log('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $tenant, context: [ + Audit::info('resource "' . $resource->key . '" assigned to plan "' . $plan->name . '"', $tenant, context: [ 'plan_id' => $plan->id, 'resource_id' => $resource->id, 'resource_key' => $resource->key, @@ -92,7 +92,7 @@ public function destroy(Tenant $tenant, Plan $plan, Resource $resource) PlanAssignments::removePlanResource($plan, $resource); - Audit::log('resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', $tenant, context: [ + Audit::info('resource "' . $resource->key . '" removed from plan "' . $plan->name . '"', $tenant, context: [ 'plan_id' => $plan->id, 'resource_id' => $resource->id, 'resource_key' => $resource->key, 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 05fdc7b..ce76f7a 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/UserController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/UserController.php @@ -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()); } 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/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/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/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/Support/Audit.php b/packages/core/src/Support/Audit.php index 5824427..382cf5c 100644 --- a/packages/core/src/Support/Audit.php +++ b/packages/core/src/Support/Audit.php @@ -2,28 +2,65 @@ namespace Froxlor\Core\Support; +use BadMethodCallException; use Froxlor\Core\Models\AuditLog; use Froxlor\Core\Models\Environment; use Froxlor\Core\Models\Tenant; use Froxlor\Core\Models\User; +/** + * @method static void debug(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null) Log debug message + * @method static void info(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null) Log debug message + * @method static void notice(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null) Log debug message + * @method static void warning(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null) Log debug message + * @method static void error(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null) Log debug message + * @method static void critical(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null) Log debug message + * @method static void alert(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null) Log debug message + * @method static void emergency(string $audit_content, ?Tenant $tenant = null, ?Environment $environment = null, ?array $context = null) Log debug message + */ class Audit { + private const array LEVELS = [ + 'debug' => 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/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/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/ResourceRegistryTest.php b/packages/core/tests/Feature/ResourceRegistryTest.php index 76a646e..cb1bdaa 100644 --- a/packages/core/tests/Feature/ResourceRegistryTest.php +++ b/packages/core/tests/Feature/ResourceRegistryTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use Froxlor\Core\Models\Node; use Froxlor\Core\Models\Resource; use Froxlor\Core\Models\User; use Froxlor\Core\Services\Traits\IsEnvironmentResource; @@ -72,26 +73,22 @@ public function test_resource_registry_rejects_conflicting_resource_keys_per_sco public function test_package_model_resources_are_seeded_automatically(): void { $this->assertDatabaseHas('resources', [ - 'key' => 'domains', - 'type' => 'environment', - 'model_type' => Domain::class, + 'key' => 'nodes', + 'type' => 'tenant', + 'model_type' => Node::class, + ]); + + $this->assertDatabaseHas('resources', [ + 'key' => 'users', + 'type' => 'tenant', + 'model_type' => User::class, ]); $this->assertDatabaseHas('resources', [ - 'key' => 'mailaddresses', + 'key' => 'users', 'type' => 'environment', - 'model_type' => MailAddress::class, + 'model_type' => User::class, ]); - $this->assertTrue(Resource::query() - ->where('key', 'users') - ->where('type', 'tenant') - ->where('model_type', User::class) - ->exists()); - $this->assertTrue(Resource::query() - ->where('key', 'users') - ->where('type', 'environment') - ->where('model_type', User::class) - ->exists()); } } 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 d8380db..0000000 --- a/packages/database/src/Models/Database.php +++ /dev/null @@ -1,64 +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 d7f4731..0000000 --- a/packages/domain/database/seeders/Testing/DomainTableSeeder.php +++ /dev/null @@ -1,45 +0,0 @@ -where([ - 'key' => 'domains', - 'type' => 'environment', - 'model_type' => Domain::class, - ])->firstOrFail(); - - // add to environment plans to be available in environment-scoped tests - foreach (['Environment Unlimited', 'Test Environment Unlimited'] as $planName) { - $plan = Plan::query()->where('name', $planName)->firstOrFail(); - $plan->resources()->syncWithoutDetaching([ - $domainResource->id => ['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 fa4c859..0000000 --- a/packages/domain/src/Models/Domain.php +++ /dev/null @@ -1,55 +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 71fcf0c..0000000 --- a/packages/ftp/routes/api.php +++ /dev/null @@ -1,23 +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 495b05e..0000000 --- a/packages/mail/database/seeders/Testing/MailTableSeeder.php +++ /dev/null @@ -1,69 +0,0 @@ -where([ - 'key' => 'mailaddresses', - 'type' => 'environment', - 'model_type' => MailAddress::class, - ])->firstOrFail(); - $mailAccResource = Resource::query()->where([ - 'key' => 'mailaccounts', - 'type' => 'environment', - 'model_type' => MailAccount::class, - ])->firstOrFail(); - - // add to environment plans to be available in environment-scoped tests - foreach (['Environment Unlimited', 'Test Environment Unlimited'] as $planName) { - $plan = Plan::query()->where('name', $planName)->firstOrFail(); - $plan->resources()->syncWithoutDetaching([ - $mailAddrResource->id => ['limit' => -1], - $mailAccResource->id => ['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 5e3b4c4..0000000 --- a/packages/mail/src/Models/MailAccount.php +++ /dev/null @@ -1,53 +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 eb470b2..0000000 --- a/packages/mail/src/Models/MailAddress.php +++ /dev/null @@ -1,51 +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 @@ -