diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..e7f7c94 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + abstract class Controller { - // + use AuthorizesRequests; } diff --git a/app/Http/Controllers/ImpersonationController.php b/app/Http/Controllers/ImpersonationController.php index 13d5e7d..8eabde8 100644 --- a/app/Http/Controllers/ImpersonationController.php +++ b/app/Http/Controllers/ImpersonationController.php @@ -16,6 +16,7 @@ class ImpersonationController extends Controller /** Admin-gated: become the customer. */ public function start(Customer $customer): RedirectResponse { + $this->authorize('customers.impersonate'); $user = $customer->ensureUser(); session(['impersonator_id' => Auth::id()]); diff --git a/app/Http/Middleware/EnsureAdmin.php b/app/Http/Middleware/EnsureAdmin.php index 1e098a1..110de1c 100644 --- a/app/Http/Middleware/EnsureAdmin.php +++ b/app/Http/Middleware/EnsureAdmin.php @@ -13,7 +13,7 @@ class EnsureAdmin */ public function handle(Request $request, Closure $next): Response { - abort_unless($request->user()?->is_admin, 403); + abort_unless((bool) $request->user()?->can('console.view'), 403); return $next($request); } diff --git a/app/Http/Middleware/EnsureCustomerActive.php b/app/Http/Middleware/EnsureCustomerActive.php index ebb3b9b..8682a54 100644 --- a/app/Http/Middleware/EnsureCustomerActive.php +++ b/app/Http/Middleware/EnsureCustomerActive.php @@ -19,7 +19,7 @@ class EnsureCustomerActive { $user = $request->user(); - if ($user !== null && ! $user->is_admin && ! $request->session()->has('impersonator_id')) { + if ($user !== null && ! $user->isOperator() && ! $request->session()->has('impersonator_id')) { $customer = Customer::query()->where('user_id', $user->id)->first() ?? Customer::query()->where('email', $user->email)->first(); diff --git a/app/Livewire/Admin/ConfirmDeleteDatacenter.php b/app/Livewire/Admin/ConfirmDeleteDatacenter.php index cb52618..bf6548c 100644 --- a/app/Livewire/Admin/ConfirmDeleteDatacenter.php +++ b/app/Livewire/Admin/ConfirmDeleteDatacenter.php @@ -30,6 +30,7 @@ class ConfirmDeleteDatacenter extends ModalComponent public function delete() { + $this->authorize('datacenters.manage'); $dc = Datacenter::query()->where('uuid', $this->uuid)->first(); if ($dc === null) { return $this->redirectRoute('admin.datacenters', navigate: true); diff --git a/app/Livewire/Admin/ConfirmRemoveHost.php b/app/Livewire/Admin/ConfirmRemoveHost.php index e8e0ba7..15e746d 100644 --- a/app/Livewire/Admin/ConfirmRemoveHost.php +++ b/app/Livewire/Admin/ConfirmRemoveHost.php @@ -26,6 +26,7 @@ class ConfirmRemoveHost extends ModalComponent public function remove() { + $this->authorize('hosts.manage'); $host = Host::query()->where('uuid', $this->uuid)->first(); if ($host !== null) { diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php index ce547e6..17fd0e4 100644 --- a/app/Livewire/Admin/Customers.php +++ b/app/Livewire/Admin/Customers.php @@ -13,6 +13,7 @@ class Customers extends Component /** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */ public function toggleSuspend(string $uuid): void { + $this->authorize('customers.manage'); $customer = Customer::query()->where('uuid', $uuid)->first(); if ($customer === null) { return; diff --git a/app/Livewire/Admin/Datacenters.php b/app/Livewire/Admin/Datacenters.php index 4cce056..514ace2 100644 --- a/app/Livewire/Admin/Datacenters.php +++ b/app/Livewire/Admin/Datacenters.php @@ -28,6 +28,7 @@ class Datacenters extends Component public function edit(string $uuid): void { + $this->authorize('datacenters.manage'); $dc = Datacenter::query()->where('uuid', $uuid)->first(); if ($dc === null) { return; @@ -44,6 +45,7 @@ class Datacenters extends Component public function update(): void { + $this->authorize('datacenters.manage'); if ($this->editingUuid === null) { return; } @@ -64,6 +66,7 @@ class Datacenters extends Component public function save(): void { + $this->authorize('datacenters.manage'); // Normalize before validating so the unique check matches how the row is // stored — otherwise `FSN` passes the rule but the lowercased insert collides. $this->code = strtolower(trim($this->code)); @@ -83,6 +86,7 @@ class Datacenters extends Component public function toggle(string $uuid): void { + $this->authorize('datacenters.manage'); $datacenter = Datacenter::query()->where('uuid', $uuid)->first(); $datacenter?->update(['active' => ! $datacenter->active]); } diff --git a/app/Livewire/Admin/HostCreate.php b/app/Livewire/Admin/HostCreate.php index 9048cc5..7790b40 100644 --- a/app/Livewire/Admin/HostCreate.php +++ b/app/Livewire/Admin/HostCreate.php @@ -29,6 +29,7 @@ class HostCreate extends Component public function save(StartHostOnboarding $action) { + $this->authorize('hosts.manage'); $data = $this->validate(); $host = $action->run($data); diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 5c27217..6cd0c56 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -30,6 +30,7 @@ class HostDetail extends Component /** Adjust the capacity reserve (% of storage kept free for headroom). */ public function saveReserve(int $reserve): void { + $this->authorize('hosts.manage'); $reserve = max(0, min(90, $reserve)); $this->host->update(['reserve_pct' => $reserve]); $this->dispatch('notify', message: __('hosts.detail.reserve_saved')); @@ -42,6 +43,7 @@ class HostDetail extends Component */ public function toggleMaintenance(): void { + $this->authorize('hosts.manage'); if ($this->host->status === 'active') { $this->host->update(['status' => 'disabled']); } elseif ($this->host->status === 'disabled') { @@ -51,6 +53,7 @@ class HostDetail extends Component public function retry(): void { + $this->authorize('hosts.manage'); $run = $this->currentRun(); if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) { diff --git a/app/Livewire/Admin/Provisioning.php b/app/Livewire/Admin/Provisioning.php index 0071092..3bf5538 100644 --- a/app/Livewire/Admin/Provisioning.php +++ b/app/Livewire/Admin/Provisioning.php @@ -26,6 +26,7 @@ class Provisioning extends Component /** Retry a failed run from its current step (mirrors the host-detail retry). */ public function retry(string $uuid): void { + $this->authorize('provisioning.retry'); $run = ProvisioningRun::query()->where('uuid', $uuid)->first(); if ($run === null || $run->status !== ProvisioningRun::STATUS_FAILED) { return; diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php new file mode 100644 index 0000000..a75a330 --- /dev/null +++ b/app/Livewire/Admin/Settings.php @@ -0,0 +1,188 @@ +name = auth()->user()->name; + $this->email = auth()->user()->email; + } + + public function saveAccount(): void + { + $user = auth()->user(); + $data = $this->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|max:255|unique:users,email,'.$user->id, + ]); + $user->update($data); + $this->dispatch('notify', message: __('admin_settings.account_saved')); + } + + public function inviteStaff(): void + { + $this->authorize('staff.manage'); + $data = $this->validate([ + 'staffName' => 'required|string|max:255', + 'staffEmail' => 'required|email|max:255|unique:users,email', + 'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only', + ]); + + // Only an Owner may create another Owner. + if ($data['staffRole'] === 'Owner' && ! auth()->user()->hasRole('Owner')) { + $this->addError('staffRole', __('admin_settings.owner_only')); + + return; + } + // Never turn a customer's portal login into an operator. + if (Customer::query()->where('email', $data['staffEmail'])->exists()) { + $this->addError('staffEmail', __('admin_settings.is_customer')); + + return; + } + + $user = User::create([ + 'name' => $data['staffName'], + 'email' => $data['staffEmail'], + 'password' => Hash::make(Str::random(40)), // invite/reset delivery is mocked + 'is_admin' => true, + ]); + $user->assignRole($data['staffRole']); + + $this->reset('staffName', 'staffEmail'); + $this->staffRole = 'Support'; + $this->dispatch('notify', message: __('admin_settings.staff_invited')); + } + + public function setStaffRole(int $id, string $role): void + { + $this->authorize('staff.manage'); + if (! in_array($role, User::OPERATOR_ROLES, true)) { + return; + } + + $result = DB::transaction(function () use ($id, $role) { + $target = User::query()->whereKey($id)->lockForUpdate()->first(); + if ($target === null) { + return 'gone'; + } + if ($target->id === auth()->id()) { + return 'self'; + } + // Granting or revoking Owner is Owner-only. + if (($role === 'Owner' || $target->hasRole('Owner')) && ! auth()->user()->hasRole('Owner')) { + return 'owner_only'; + } + // Never demote the last Owner. + if ($target->hasRole('Owner') && $role !== 'Owner' && $this->ownerCount() <= 1) { + return 'last_owner'; + } + $target->syncRoles([$role]); + + return 'ok'; + }); + + $this->flash($result); + } + + public function revokeStaff(int $id): void + { + $this->authorize('staff.manage'); + + $result = DB::transaction(function () use ($id) { + $target = User::query()->whereKey($id)->lockForUpdate()->first(); + if ($target === null) { + return 'gone'; + } + if ($target->id === auth()->id()) { + return 'self'; + } + if ($target->hasRole('Owner') && $this->ownerCount() <= 1) { + return 'last_owner'; + } + $target->syncRoles([]); + $target->update(['is_admin' => false]); + + return 'revoked'; + }); + + $this->flash($result); + } + + private function flash(string $result): void + { + $msg = match ($result) { + 'ok' => __('admin_settings.role_updated'), + 'revoked' => __('admin_settings.staff_revoked'), + 'self' => __('admin_settings.not_self'), + 'owner_only' => __('admin_settings.owner_only'), + 'last_owner' => __('admin_settings.last_owner'), + default => null, + }; + if ($msg !== null) { + $this->dispatch('notify', message: $msg); + } + } + + private function ownerCount(): int + { + return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count(); + } + + public function render() + { + $staff = User::query() + ->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES)) + ->with('roles') + ->orderBy('name') + ->get() + ->map(fn (User $u) => [ + 'id' => $u->id, + 'name' => $u->name, + 'email' => $u->email, + 'role' => $u->roles->first()?->name ?? '—', + 'self' => $u->id === auth()->id(), + ]); + + return view('livewire.admin.settings', [ + 'staff' => $staff, + 'roles' => User::OPERATOR_ROLES, + 'canManageStaff' => auth()->user()->can('staff.manage'), + 'isOwner' => auth()->user()->hasRole('Owner'), + ]); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index b19b849..dd5232c 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -110,9 +110,9 @@ class Customer extends Model private function assertNotAdmin(User $user): void { - if ($user->is_admin) { + if ($user->is_admin || $user->isOperator()) { throw new RuntimeException( - "Refusing to link admin account {$user->email} as a portal login for customer {$this->id}.", + "Refusing to link operator account {$user->email} as a portal login for customer {$this->id}.", ); } } diff --git a/app/Models/User.php b/app/Models/User.php index de91f96..239ab1c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,13 +10,23 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; +use Spatie\Permission\Traits\HasRoles; #[Fillable(['name', 'email', 'password', 'is_admin'])] #[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable; + + /** The five operator roles that grant access to the admin console. */ + public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only']; + + /** True when this user is a CluPilot operator (has any admin role). */ + public function isOperator(): bool + { + return $this->hasAnyRole(self::OPERATOR_ROLES); + } /** * Get the attributes that should be cast. diff --git a/composer.json b/composer.json index cc651c2..6ea5080 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "laravel/tinker": "^3.0", "livewire/livewire": "^3.0", "phpseclib/phpseclib": "^3.0", + "spatie/laravel-permission": "^8.3", "wire-elements/modal": "^3.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 4b5a63c..67a6ce6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7e56760c9cbc798ebd3f4f9131a703fb", + "content-hash": "090088615979540ccfee56eba3d133af", "packages": [ { "name": "bacon/bacon-qr-code", @@ -5137,6 +5137,94 @@ ], "time": "2026-05-19T14:06:37+00:00" }, + { + "name": "spatie/laravel-permission", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "60e8ed5b2fbf043c2264433fc2680c76b8b66aa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/60e8ed5b2fbf043c2264433fc2680c76b8b66aa6", + "reference": "60e8ed5b2fbf043c2264433fc2680c76b8b66aa6", + "shasum": "" + }, + "require": { + "illuminate/auth": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/octane": "^2.0", + "laravel/passport": "^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.1", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "8.x-dev", + "dev-master": "8.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 12 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-07-03T15:36:01+00:00" + }, { "name": "spomky-labs/cbor-php", "version": "3.3.0", diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..8f1f452 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 2a4ee9c..7c3465f 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -42,4 +42,23 @@ class UserFactory extends Factory 'email_verified_at' => null, ]); } + + /** + * Legacy compatibility: a user created with is_admin => true is an operator, + * so give them the full-control Owner role (roles are seeded by migration). + */ + public function configure(): static + { + return $this->afterCreating(function (User $user) { + if ($user->is_admin && ! $user->hasAnyRole(User::OPERATOR_ROLES)) { + $user->assignRole('Owner'); + } + }); + } + + /** Explicitly give the user an operator role. */ + public function operator(string $role = 'Owner'): static + { + return $this->afterCreating(fn (User $user) => $user->syncRoles([$role])); + } } diff --git a/database/migrations/2026_07_25_133845_create_permission_tables.php b/database/migrations/2026_07_25_133845_create_permission_tables.php new file mode 100644 index 0000000..8986275 --- /dev/null +++ b/database/migrations/2026_07_25_133845_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/database/migrations/2026_07_25_133900_seed_roles_and_permissions.php b/database/migrations/2026_07_25_133900_seed_roles_and_permissions.php new file mode 100644 index 0000000..ec35d43 --- /dev/null +++ b/database/migrations/2026_07_25_133900_seed_roles_and_permissions.php @@ -0,0 +1,56 @@ +forgetCachedPermissions(); + + $capabilities = [ + 'console.view', + 'hosts.manage', + 'datacenters.manage', + 'customers.manage', + 'customers.impersonate', + 'provisioning.retry', + 'provisioning.cancel', + 'billing.manage', + 'maintenance.manage', + 'staff.manage', + ]; + foreach ($capabilities as $name) { + Permission::findOrCreate($name, 'web'); + } + + $matrix = [ + 'Owner' => $capabilities, // full control + 'Admin' => ['console.view', 'hosts.manage', 'datacenters.manage', 'customers.manage', 'customers.impersonate', 'provisioning.retry', 'provisioning.cancel', 'maintenance.manage'], + 'Support' => ['console.view', 'customers.manage', 'customers.impersonate', 'provisioning.retry'], + 'Billing' => ['console.view', 'billing.manage'], + 'Read-only' => ['console.view'], + ]; + foreach ($matrix as $roleName => $perms) { + Role::findOrCreate($roleName, 'web')->syncPermissions($perms); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Role::query()->whereIn('name', ['Owner', 'Admin', 'Support', 'Billing', 'Read-only'])->delete(); + Permission::query()->delete(); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9c1450a..31d0a4a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -24,7 +24,7 @@ class DatabaseSeeder extends Seeder return; } - User::updateOrCreate( + $admin = User::updateOrCreate( ['email' => env('SEED_ADMIN_EMAIL', 'admin@clupilot.local')], [ 'name' => 'Admin', @@ -32,6 +32,9 @@ class DatabaseSeeder extends Seeder 'is_admin' => true, ], ); + if (! $admin->hasRole('Owner')) { + $admin->assignRole('Owner'); + } // A plain customer to exercise the portal (and prove the admin gate). User::updateOrCreate( diff --git a/lang/de/admin.php b/lang/de/admin.php index c39b7b0..551a08e 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -13,6 +13,7 @@ return [ 'datacenters' => 'Rechenzentren', 'provisioning' => 'Provisioning', 'revenue' => 'Umsatz', + 'settings' => 'Einstellungen', ], 'overview_title' => 'Fleet-Übersicht', diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php new file mode 100644 index 0000000..4592d43 --- /dev/null +++ b/lang/de/admin_settings.php @@ -0,0 +1,38 @@ + 'Einstellungen', + 'subtitle' => 'Ihr Operator-Konto und die Verwaltung Ihres Teams.', + 'save' => 'Speichern', + + 'account_title' => 'Mein Konto', + 'name' => 'Name', + 'email' => 'E-Mail', + 'twofa_hint' => 'Passwort & Zwei-Faktor-Authentifizierung folgen in einem separaten Sicherheitsbereich.', + 'account_saved' => 'Konto aktualisiert.', + + 'staff_title' => 'Team & Zugriff', + 'staff_sub' => 'Mitarbeiter einladen und deren Rolle festlegen. Nur der Inhaber kann das Team verwalten.', + 'role' => 'Rolle', + 'invite' => 'Einladen', + 'invite_hint' => 'Der Zugang wird angelegt; die Einladungs-/Passwort-Zustellung folgt mit der E-Mail-Anbindung.', + 'staff_invited' => 'Mitarbeiter angelegt.', + + 'col_person' => 'Person', + 'col_actions' => 'Aktionen', + 'you' => 'Sie', + 'revoke' => 'Zugriff entziehen', + + 'role_owner' => 'Inhaber', + 'role_admin' => 'Administrator', + 'role_support' => 'Support', + 'role_billing' => 'Abrechnung', + 'role_read-only' => 'Nur Lesen', + + 'role_updated' => 'Rolle aktualisiert.', + 'staff_revoked' => 'Zugriff entzogen.', + 'not_self' => 'Sie können Ihre eigene Rolle nicht ändern.', + 'owner_only' => 'Nur ein Inhaber darf die Inhaber-Rolle vergeben oder entziehen.', + 'last_owner' => 'Der letzte Inhaber kann nicht geändert oder entfernt werden.', + 'is_customer' => 'Diese E-Mail gehört bereits zu einem Kundenkonto.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 616c6de..598a48c 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -13,6 +13,7 @@ return [ 'datacenters' => 'Datacenters', 'provisioning' => 'Provisioning', 'revenue' => 'Revenue', + 'settings' => 'Settings', ], 'overview_title' => 'Fleet overview', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php new file mode 100644 index 0000000..783ecde --- /dev/null +++ b/lang/en/admin_settings.php @@ -0,0 +1,38 @@ + 'Settings', + 'subtitle' => 'Your operator account and team management.', + 'save' => 'Save', + + 'account_title' => 'My account', + 'name' => 'Name', + 'email' => 'Email', + 'twofa_hint' => 'Password & two-factor authentication follow in a separate security area.', + 'account_saved' => 'Account updated.', + + 'staff_title' => 'Team & access', + 'staff_sub' => 'Invite staff and set their role. Only the Owner can manage the team.', + 'role' => 'Role', + 'invite' => 'Invite', + 'invite_hint' => 'The account is created; invitation/password delivery follows with the email integration.', + 'staff_invited' => 'Staff member created.', + + 'col_person' => 'Person', + 'col_actions' => 'Actions', + 'you' => 'You', + 'revoke' => 'Revoke access', + + 'role_owner' => 'Owner', + 'role_admin' => 'Administrator', + 'role_support' => 'Support', + 'role_billing' => 'Billing', + 'role_read-only' => 'Read-only', + + 'role_updated' => 'Role updated.', + 'staff_revoked' => 'Access revoked.', + 'not_self' => 'You cannot change your own role.', + 'owner_only' => 'Only an Owner may grant or revoke the Owner role.', + 'last_owner' => 'The last Owner cannot be changed or removed.', + 'is_customer' => 'This email already belongs to a customer account.', +]; diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index d640712..127af70 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -43,10 +43,11 @@ @endforeach -
- - {{ __('admin.to_portal') }} - +
+ + + {{ __('admin.nav.settings') }} +
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php new file mode 100644 index 0000000..75fdfd1 --- /dev/null +++ b/resources/views/livewire/admin/settings.blade.php @@ -0,0 +1,103 @@ +
+
+

{{ __('admin_settings.title') }}

+

{{ __('admin_settings.subtitle') }}

+
+ + {{-- My account --}} +
+

{{ __('admin_settings.account_title') }}

+
+ + +
+

{{ __('admin_settings.twofa_hint') }}

+
+ {{ __('admin_settings.save') }} +
+
+ + {{-- Staff & access (Owner-only) --}} + @if ($canManageStaff) +
+
+

{{ __('admin_settings.staff_title') }}

+

{{ __('admin_settings.staff_sub') }}

+
+ + {{-- Invite --}} +
+
+ + + @error('staffName')

{{ $message }}

@enderror +
+
+ + + @error('staffEmail')

{{ $message }}

@enderror +
+
+ + + @error('staffRole')

{{ $message }}

@enderror +
+ + {{ __('admin_settings.invite') }} + +
+

{{ __('admin_settings.invite_hint') }}

+ + {{-- Staff list --}} +
+
+ + + + + + + + + + @foreach ($staff as $s) + + + + + + @endforeach + +
{{ __('admin_settings.col_person') }}{{ __('admin_settings.role') }}{{ __('admin_settings.col_actions') }}
+

{{ $s['name'] }} + @if ($s['self'])({{ __('admin_settings.you') }})@endif +

+

{{ $s['email'] }}

+
+ @if ($s['self']) + {{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($s['role'])) }} + @else + + @endif + + @unless ($s['self']) + + @endunless +
+
+
+
+ @endif +
diff --git a/routes/channels.php b/routes/channels.php index 052e3b9..9a9668a 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -6,8 +6,8 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) { return (int) $user->id === (int) $id; }); -// Operator console live provisioning feed — admins only. -Broadcast::channel('admin.runs', fn ($user) => (bool) $user->is_admin); +// Operator console live provisioning feed — operators only. +Broadcast::channel('admin.runs', fn ($user) => (bool) $user->can('console.view')); // A customer's own provisioning feed — bridged from the auth user by email. Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) { diff --git a/routes/web.php b/routes/web.php index 187d689..4774cbc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -64,4 +64,5 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); + Route::get('/settings', Admin\Settings::class)->name('settings'); }); diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php new file mode 100644 index 0000000..e3edbd5 --- /dev/null +++ b/tests/Feature/Admin/AdminSettingsTest.php @@ -0,0 +1,58 @@ +test(Settings::class) + ->set('name', 'New Name')->set('email', 'new@ops.test')->call('saveAccount')->assertHasNoErrors(); + + expect($owner->fresh()->name)->toBe('New Name')->and($owner->fresh()->email)->toBe('new@ops.test'); +}); + +it('lets an Owner invite staff with a role', function () { + Livewire::actingAs(operator('Owner'))->test(Settings::class) + ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support') + ->call('inviteStaff')->assertHasNoErrors(); + + $sam = User::query()->where('email', 'sam@ops.test')->first(); + expect($sam)->not->toBeNull()->and($sam->hasRole('Support'))->toBeTrue(); +}); + +it('forbids a non-Owner from managing staff', function () { + Livewire::actingAs(operator('Admin'))->test(Settings::class) + ->set('staffName', 'X')->set('staffEmail', 'x@ops.test')->set('staffRole', 'Support') + ->call('inviteStaff')->assertForbidden(); + expect(User::query()->where('email', 'x@ops.test')->exists())->toBeFalse(); +}); + +it('will not create an operator from a customer email', function () { + Customer::factory()->create(['email' => 'dup@ops.test']); + + Livewire::actingAs(operator('Owner'))->test(Settings::class) + ->set('staffName', 'Dup')->set('staffEmail', 'dup@ops.test')->set('staffRole', 'Support') + ->call('inviteStaff')->assertHasErrors(['staffEmail']); +}); + +it('protects the last owner and self-role', function () { + $owner = operator('Owner'); + $support = operator('Support'); + + // Cannot change own role. + Livewire::actingAs($owner)->test(Settings::class)->call('setStaffRole', $owner->id, 'Admin'); + expect($owner->fresh()->hasRole('Owner'))->toBeTrue(); + + // Only one owner → cannot revoke it. + Livewire::actingAs($owner)->test(Settings::class)->call('revokeStaff', $owner->id); + expect($owner->fresh()->hasRole('Owner'))->toBeTrue(); + + // Owner can change another operator's role. + Livewire::actingAs($owner)->test(Settings::class)->call('setStaffRole', $support->id, 'Billing'); + expect($support->fresh()->hasRole('Billing'))->toBeTrue()->and($support->fresh()->hasRole('Support'))->toBeFalse(); +}); diff --git a/tests/Feature/Admin/RbacTest.php b/tests/Feature/Admin/RbacTest.php new file mode 100644 index 0000000..34b672f --- /dev/null +++ b/tests/Feature/Admin/RbacTest.php @@ -0,0 +1,42 @@ +operator($role)->create(); +} + +it('grants console access to every operator role but not to customers', function () { + foreach (['Owner', 'Admin', 'Support', 'Billing', 'Read-only'] as $role) { + $this->actingAs(operator($role))->get(route('admin.overview'))->assertOk(); + } + $customer = User::factory()->create(); // no role + $this->actingAs($customer)->get(route('admin.overview'))->assertForbidden(); +}); + +it('denies datacenter management to roles without the capability', function () { + foreach (['Support', 'Billing', 'Read-only'] as $role) { + Livewire::actingAs(operator($role))->test(Datacenters::class) + ->set('code', 'xy')->set('name', 'X')->call('save')->assertForbidden(); + } + expect(Datacenter::query()->where('code', 'xy')->exists())->toBeFalse(); + + // Admin has the capability. + Livewire::actingAs(operator('Admin'))->test(Datacenters::class) + ->set('code', 'nbg2')->set('name', 'Nürnberg')->call('save')->assertHasNoErrors(); + expect(Datacenter::query()->where('code', 'nbg2')->exists())->toBeTrue(); +}); + +it('lets Support retry provisioning but not manage datacenters', function () { + // Support can retry (capability present) — the action runs (no-op on a missing run). + Livewire::actingAs(operator('Support'))->test(Provisioning::class)->call('retry', 'nonexistent-uuid')->assertOk(); + + // ...but cannot manage datacenters. + Livewire::actingAs(operator('Support'))->test(Datacenters::class)->call('toggle', 'x')->assertForbidden(); +});