feat(admin): staff RBAC (spatie) + admin settings page

- 5 operator roles (Owner/Admin/Support/Billing/Read-only) seeded via migration
  with a capability catalogue; app checks capabilities via Gate, never role names
- every mutating admin action authorizes server-side (hosts/datacenters/customers/
  impersonate/provisioning); is_admin reads migrated to console.view / isOperator()
- admin /settings: own account + Owner-only staff invite/role/revoke with
  last-owner, self-role and customer-collision guards (transactional)
- sidebar 'zum Kundenportal' link replaced with Settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 15:47:42 +02:00
parent 4336c3bb3f
commit c5d60340b7
31 changed files with 1033 additions and 14 deletions

View File

@ -2,7 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller abstract class Controller
{ {
// use AuthorizesRequests;
} }

View File

@ -16,6 +16,7 @@ class ImpersonationController extends Controller
/** Admin-gated: become the customer. */ /** Admin-gated: become the customer. */
public function start(Customer $customer): RedirectResponse public function start(Customer $customer): RedirectResponse
{ {
$this->authorize('customers.impersonate');
$user = $customer->ensureUser(); $user = $customer->ensureUser();
session(['impersonator_id' => Auth::id()]); session(['impersonator_id' => Auth::id()]);

View File

@ -13,7 +13,7 @@ class EnsureAdmin
*/ */
public function handle(Request $request, Closure $next): Response 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); return $next($request);
} }

View File

@ -19,7 +19,7 @@ class EnsureCustomerActive
{ {
$user = $request->user(); $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 = Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first(); ?? Customer::query()->where('email', $user->email)->first();

View File

@ -30,6 +30,7 @@ class ConfirmDeleteDatacenter extends ModalComponent
public function delete() public function delete()
{ {
$this->authorize('datacenters.manage');
$dc = Datacenter::query()->where('uuid', $this->uuid)->first(); $dc = Datacenter::query()->where('uuid', $this->uuid)->first();
if ($dc === null) { if ($dc === null) {
return $this->redirectRoute('admin.datacenters', navigate: true); return $this->redirectRoute('admin.datacenters', navigate: true);

View File

@ -26,6 +26,7 @@ class ConfirmRemoveHost extends ModalComponent
public function remove() public function remove()
{ {
$this->authorize('hosts.manage');
$host = Host::query()->where('uuid', $this->uuid)->first(); $host = Host::query()->where('uuid', $this->uuid)->first();
if ($host !== null) { if ($host !== null) {

View File

@ -13,6 +13,7 @@ class Customers extends Component
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */ /** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
public function toggleSuspend(string $uuid): void public function toggleSuspend(string $uuid): void
{ {
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->first(); $customer = Customer::query()->where('uuid', $uuid)->first();
if ($customer === null) { if ($customer === null) {
return; return;

View File

@ -28,6 +28,7 @@ class Datacenters extends Component
public function edit(string $uuid): void public function edit(string $uuid): void
{ {
$this->authorize('datacenters.manage');
$dc = Datacenter::query()->where('uuid', $uuid)->first(); $dc = Datacenter::query()->where('uuid', $uuid)->first();
if ($dc === null) { if ($dc === null) {
return; return;
@ -44,6 +45,7 @@ class Datacenters extends Component
public function update(): void public function update(): void
{ {
$this->authorize('datacenters.manage');
if ($this->editingUuid === null) { if ($this->editingUuid === null) {
return; return;
} }
@ -64,6 +66,7 @@ class Datacenters extends Component
public function save(): void public function save(): void
{ {
$this->authorize('datacenters.manage');
// Normalize before validating so the unique check matches how the row is // Normalize before validating so the unique check matches how the row is
// stored — otherwise `FSN` passes the rule but the lowercased insert collides. // stored — otherwise `FSN` passes the rule but the lowercased insert collides.
$this->code = strtolower(trim($this->code)); $this->code = strtolower(trim($this->code));
@ -83,6 +86,7 @@ class Datacenters extends Component
public function toggle(string $uuid): void public function toggle(string $uuid): void
{ {
$this->authorize('datacenters.manage');
$datacenter = Datacenter::query()->where('uuid', $uuid)->first(); $datacenter = Datacenter::query()->where('uuid', $uuid)->first();
$datacenter?->update(['active' => ! $datacenter->active]); $datacenter?->update(['active' => ! $datacenter->active]);
} }

View File

@ -29,6 +29,7 @@ class HostCreate extends Component
public function save(StartHostOnboarding $action) public function save(StartHostOnboarding $action)
{ {
$this->authorize('hosts.manage');
$data = $this->validate(); $data = $this->validate();
$host = $action->run($data); $host = $action->run($data);

View File

@ -30,6 +30,7 @@ class HostDetail extends Component
/** Adjust the capacity reserve (% of storage kept free for headroom). */ /** Adjust the capacity reserve (% of storage kept free for headroom). */
public function saveReserve(int $reserve): void public function saveReserve(int $reserve): void
{ {
$this->authorize('hosts.manage');
$reserve = max(0, min(90, $reserve)); $reserve = max(0, min(90, $reserve));
$this->host->update(['reserve_pct' => $reserve]); $this->host->update(['reserve_pct' => $reserve]);
$this->dispatch('notify', message: __('hosts.detail.reserve_saved')); $this->dispatch('notify', message: __('hosts.detail.reserve_saved'));
@ -42,6 +43,7 @@ class HostDetail extends Component
*/ */
public function toggleMaintenance(): void public function toggleMaintenance(): void
{ {
$this->authorize('hosts.manage');
if ($this->host->status === 'active') { if ($this->host->status === 'active') {
$this->host->update(['status' => 'disabled']); $this->host->update(['status' => 'disabled']);
} elseif ($this->host->status === 'disabled') { } elseif ($this->host->status === 'disabled') {
@ -51,6 +53,7 @@ class HostDetail extends Component
public function retry(): void public function retry(): void
{ {
$this->authorize('hosts.manage');
$run = $this->currentRun(); $run = $this->currentRun();
if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) { if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) {

View File

@ -26,6 +26,7 @@ class Provisioning extends Component
/** Retry a failed run from its current step (mirrors the host-detail retry). */ /** Retry a failed run from its current step (mirrors the host-detail retry). */
public function retry(string $uuid): void public function retry(string $uuid): void
{ {
$this->authorize('provisioning.retry');
$run = ProvisioningRun::query()->where('uuid', $uuid)->first(); $run = ProvisioningRun::query()->where('uuid', $uuid)->first();
if ($run === null || $run->status !== ProvisioningRun::STATUS_FAILED) { if ($run === null || $run->status !== ProvisioningRun::STATUS_FAILED) {
return; return;

View File

@ -0,0 +1,188 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* Operator settings: the signed-in operator's own account plus (Owner-only)
* staff management. All staff mutations are capability-gated server-side and
* protect the last-Owner and self-role invariants transactionally.
*/
#[Layout('layouts.admin')]
class Settings extends Component
{
// My account
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|email|max:255')]
public string $email = '';
// Invite staff
#[Validate('required|string|max:255')]
public string $staffName = '';
#[Validate('required|email|max:255')]
public string $staffEmail = '';
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
public string $staffRole = 'Support';
public function mount(): void
{
$this->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'),
]);
}
}

View File

@ -110,9 +110,9 @@ class Customer extends Model
private function assertNotAdmin(User $user): void private function assertNotAdmin(User $user): void
{ {
if ($user->is_admin) { if ($user->is_admin || $user->isOperator()) {
throw new RuntimeException( 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}.",
); );
} }
} }

View File

@ -10,13 +10,23 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password', 'is_admin'])] #[Fillable(['name', 'email', 'password', 'is_admin'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] #[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
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. * Get the attributes that should be cast.

View File

@ -13,6 +13,7 @@
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"livewire/livewire": "^3.0", "livewire/livewire": "^3.0",
"phpseclib/phpseclib": "^3.0", "phpseclib/phpseclib": "^3.0",
"spatie/laravel-permission": "^8.3",
"wire-elements/modal": "^3.0" "wire-elements/modal": "^3.0"
}, },
"require-dev": { "require-dev": {

90
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7e56760c9cbc798ebd3f4f9131a703fb", "content-hash": "090088615979540ccfee56eba3d133af",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -5137,6 +5137,94 @@
], ],
"time": "2026-05-19T14:06:37+00:00" "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", "name": "spomky-labs/cbor-php",
"version": "3.3.0", "version": "3.3.0",

219
config/permission.php Normal file
View File

@ -0,0 +1,219 @@
<?php
use Spatie\Permission\DefaultTeamResolver;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return [
'models' => [
/*
* 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',
],
];

View File

@ -42,4 +42,23 @@ class UserFactory extends Factory
'email_verified_at' => null, '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]));
}
} }

View File

@ -0,0 +1,137 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* 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['permissions'], static function (Blueprint $table) {
$table->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']);
}
};

View File

@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Seed the operator RBAC catalogue in a migration (not a seeder) so the five
* roles + capability permissions exist in every environment, including the
* test database built by RefreshDatabase. Application code checks capabilities
* (e.g. `hosts.manage`) via Laravel's Gate never role names.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->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();
}
};

View File

@ -24,7 +24,7 @@ class DatabaseSeeder extends Seeder
return; return;
} }
User::updateOrCreate( $admin = User::updateOrCreate(
['email' => env('SEED_ADMIN_EMAIL', 'admin@clupilot.local')], ['email' => env('SEED_ADMIN_EMAIL', 'admin@clupilot.local')],
[ [
'name' => 'Admin', 'name' => 'Admin',
@ -32,6 +32,9 @@ class DatabaseSeeder extends Seeder
'is_admin' => true, 'is_admin' => true,
], ],
); );
if (! $admin->hasRole('Owner')) {
$admin->assignRole('Owner');
}
// A plain customer to exercise the portal (and prove the admin gate). // A plain customer to exercise the portal (and prove the admin gate).
User::updateOrCreate( User::updateOrCreate(

View File

@ -13,6 +13,7 @@ return [
'datacenters' => 'Rechenzentren', 'datacenters' => 'Rechenzentren',
'provisioning' => 'Provisioning', 'provisioning' => 'Provisioning',
'revenue' => 'Umsatz', 'revenue' => 'Umsatz',
'settings' => 'Einstellungen',
], ],
'overview_title' => 'Fleet-Übersicht', 'overview_title' => 'Fleet-Übersicht',

View File

@ -0,0 +1,38 @@
<?php
return [
'title' => '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.',
];

View File

@ -13,6 +13,7 @@ return [
'datacenters' => 'Datacenters', 'datacenters' => 'Datacenters',
'provisioning' => 'Provisioning', 'provisioning' => 'Provisioning',
'revenue' => 'Revenue', 'revenue' => 'Revenue',
'settings' => 'Settings',
], ],
'overview_title' => 'Fleet overview', 'overview_title' => 'Fleet overview',

View File

@ -0,0 +1,38 @@
<?php
return [
'title' => '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.',
];

View File

@ -43,10 +43,11 @@
</x-ui.nav-item> </x-ui.nav-item>
@endforeach @endforeach
</nav> </nav>
<div class="mt-auto border-t border-line px-2 pt-4"> <div class="mt-auto space-y-1 border-t border-line pt-4">
<a href="{{ route('dashboard') }}" class="flex items-center gap-2 rounded px-2 py-2 text-xs font-medium text-muted hover:bg-surface-hover hover:text-body"> <x-ui.nav-item :href="route('admin.settings')" :active="request()->routeIs('admin.settings')">
<x-ui.icon name="external-link" class="size-4" />{{ __('admin.to_portal') }} <x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
</a> {{ __('admin.nav.settings') }}
</x-ui.nav-item>
</div> </div>
</aside> </aside>

View File

@ -0,0 +1,103 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
</div>
{{-- My account --}}
<form wire:submit="saveAccount" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" />
<x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
</div>
<p class="text-xs text-faint">{{ __('admin_settings.twofa_hint') }}</p>
<div class="flex justify-end">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
</div>
</form>
{{-- Staff & access (Owner-only) --}}
@if ($canManageStaff)
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.staff_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.staff_sub') }}</p>
</div>
{{-- Invite --}}
<form wire:submit="inviteStaff" class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
<div>
<label class="text-sm font-medium text-body" for="staffName">{{ __('admin_settings.name') }}</label>
<input id="staffName" type="text" wire:model="staffName" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('staffName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="staffEmail">{{ __('admin_settings.email') }}</label>
<input id="staffEmail" type="email" wire:model="staffEmail" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('staffEmail')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="staffRole">{{ __('admin_settings.role') }}</label>
<select id="staffRole" wire:model="staffRole" class="mt-1.5 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@foreach ($roles as $r)
@if ($r !== 'Owner' || $isOwner)
<option value="{{ $r }}">{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($r)) }}</option>
@endif
@endforeach
</select>
@error('staffRole')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="inviteStaff">
<x-ui.icon name="plus" class="size-4" />{{ __('admin_settings.invite') }}
</x-ui.button>
</form>
<p class="text-xs text-faint">{{ __('admin_settings.invite_hint') }}</p>
{{-- Staff list --}}
<div class="overflow-hidden rounded-xl border border-line">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.col_person') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.role') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('admin_settings.col_actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($staff as $s)
<tr wire:key="staff-{{ $s['id'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3">
<p class="font-medium text-ink">{{ $s['name'] }}
@if ($s['self'])<span class="ml-1 text-xs text-faint">({{ __('admin_settings.you') }})</span>@endif
</p>
<p class="text-xs text-muted">{{ $s['email'] }}</p>
</td>
<td class="px-4 py-3">
@if ($s['self'])
<span class="text-body">{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($s['role'])) }}</span>
@else
<select wire:change="setStaffRole({{ $s['id'] }}, $event.target.value)" class="rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-body">
@foreach ($roles as $r)
@if ($r !== 'Owner' || $isOwner)
<option value="{{ $r }}" @selected($s['role'] === $r)>{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($r)) }}</option>
@endif
@endforeach
</select>
@endif
</td>
<td class="px-4 py-3 text-right">
@unless ($s['self'])
<button type="button" wire:click="revokeStaff({{ $s['id'] }})" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-danger hover:text-danger">{{ __('admin_settings.revoke') }}</button>
@endunless
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
</div>

View File

@ -6,8 +6,8 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id; return (int) $user->id === (int) $id;
}); });
// Operator console live provisioning feed — admins only. // Operator console live provisioning feed — operators only.
Broadcast::channel('admin.runs', fn ($user) => (bool) $user->is_admin); Broadcast::channel('admin.runs', fn ($user) => (bool) $user->can('console.view'));
// A customer's own provisioning feed — bridged from the auth user by email. // A customer's own provisioning feed — bridged from the auth user by email.
Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) { Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) {

View File

@ -64,4 +64,5 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate');
Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/revenue', Admin\Revenue::class)->name('revenue');
Route::get('/settings', Admin\Settings::class)->name('settings');
}); });

View File

@ -0,0 +1,58 @@
<?php
use App\Livewire\Admin\Settings;
use App\Models\Customer;
use App\Models\User;
use Livewire\Livewire;
// operator() helper defined in RbacTest.
it('updates the operator own account', function () {
$owner = operator('Owner');
Livewire::actingAs($owner)->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();
});

View File

@ -0,0 +1,42 @@
<?php
use App\Livewire\Admin\Datacenters;
use App\Livewire\Admin\Provisioning;
use App\Models\Datacenter;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Livewire\Livewire;
function operator(string $role): User
{
return User::factory()->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();
});