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
parent
4336c3bb3f
commit
c5d60340b7
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class HostCreate extends Component
|
|||
|
||||
public function save(StartHostOnboarding $action)
|
||||
{
|
||||
$this->authorize('hosts.manage');
|
||||
$data = $this->validate();
|
||||
|
||||
$host = $action->run($data);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ return [
|
|||
'datacenters' => 'Rechenzentren',
|
||||
'provisioning' => 'Provisioning',
|
||||
'revenue' => 'Umsatz',
|
||||
'settings' => 'Einstellungen',
|
||||
],
|
||||
|
||||
'overview_title' => 'Fleet-Übersicht',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -13,6 +13,7 @@ return [
|
|||
'datacenters' => 'Datacenters',
|
||||
'provisioning' => 'Provisioning',
|
||||
'revenue' => 'Revenue',
|
||||
'settings' => 'Settings',
|
||||
],
|
||||
|
||||
'overview_title' => 'Fleet overview',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -43,10 +43,11 @@
|
|||
</x-ui.nav-item>
|
||||
@endforeach
|
||||
</nav>
|
||||
<div class="mt-auto border-t border-line px-2 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.icon name="external-link" class="size-4" />{{ __('admin.to_portal') }}
|
||||
</a>
|
||||
<div class="mt-auto space-y-1 border-t border-line pt-4">
|
||||
<x-ui.nav-item :href="route('admin.settings')" :active="request()->routeIs('admin.settings')">
|
||||
<x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
|
||||
{{ __('admin.nav.settings') }}
|
||||
</x-ui.nav-item>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue