From 828e70fbe23610bf8b50060c5550f43d854183df Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 17 Jun 2026 16:39:28 +0200 Subject: [PATCH] feat(permissions): admin role/permission matrix + Gate::before super-admin Phase 1 (additive, doesn't touch existing checks): - Gate::before grants everything to holders of 'manage all' (the Admin role), robustly (returns true/null, never false; swallows missing-permission). - New RolePermissionManager Livewire component + view at /admin/permissions: editable Roles x Permissions matrix (toggle saves instantly), create/delete roles, create/delete permissions. Admin role and 'manage all' are protected. - Link to the screen from /admin/users header. Roles are editable from the UI as chosen. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/RolePermissionManager.php | 110 ++++++++++++++++++ app/Providers/AppServiceProvider.php | 12 +- resources/views/admin/users.blade.php | 13 ++- .../role-permission-manager.blade.php | 87 ++++++++++++++ routes/web.php | 1 + 5 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 app/Livewire/RolePermissionManager.php create mode 100644 resources/views/livewire/role-permission-manager.blade.php diff --git a/app/Livewire/RolePermissionManager.php b/app/Livewire/RolePermissionManager.php new file mode 100644 index 0000000..0671ce5 --- /dev/null +++ b/app/Livewire/RolePermissionManager.php @@ -0,0 +1,110 @@ +can(self::CORE_PERMISSION), 403); + } + + private function flushCache(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function togglePermission(int $roleId, string $permissionName): void + { + $role = Role::findOrFail($roleId); + + if ($role->hasPermissionTo($permissionName)) { + // Admin must always keep the core permission + if ($role->name === 'Admin' && $permissionName === self::CORE_PERMISSION) { + $this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'."); + return; + } + $role->revokePermissionTo($permissionName); + } else { + $role->givePermissionTo($permissionName); + } + + $this->flushCache(); + $this->dispatch('notify', 'Permisos actualizados'); + } + + public function addRole(): void + { + $this->validate([ + 'newRole' => 'required|string|max:50|unique:roles,name', + ], [], ['newRole' => 'nombre de rol']); + + Role::create(['name' => trim($this->newRole)]); + $this->newRole = ''; + $this->flushCache(); + $this->dispatch('notify', 'Rol creado'); + } + + public function deleteRole(int $roleId): void + { + $role = Role::findOrFail($roleId); + + if (in_array($role->name, self::PROTECTED_ROLES, true)) { + $this->dispatch('notify', "El rol '{$role->name}' está protegido y no se puede borrar."); + return; + } + + $role->delete(); + $this->flushCache(); + $this->dispatch('notify', 'Rol eliminado'); + } + + public function addPermission(): void + { + $this->validate([ + 'newPermission' => 'required|string|max:50|unique:permissions,name', + ], [], ['newPermission' => 'nombre de permiso']); + + Permission::create(['name' => trim($this->newPermission)]); + $this->newPermission = ''; + $this->flushCache(); + $this->dispatch('notify', 'Permiso creado'); + } + + public function deletePermission(int $permissionId): void + { + $permission = Permission::findOrFail($permissionId); + + if ($permission->name === self::CORE_PERMISSION) { + $this->dispatch('notify', "El permiso '" . self::CORE_PERMISSION . "' está protegido y no se puede borrar."); + return; + } + + $permission->delete(); + $this->flushCache(); + $this->dispatch('notify', 'Permiso eliminado'); + } + + public function render() + { + return view('livewire.role-permission-manager', [ + 'roles' => Role::with('permissions')->orderBy('name')->get(), + 'permissions' => Permission::orderBy('name')->get(), + ]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..26a92a5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\Gate; class AppServiceProvider extends ServiceProvider { @@ -19,6 +20,15 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + // Super-admin bypass: anyone with the "manage all" permission + // (the Admin role has it) passes every authorization check. + // Return true to allow, or null to let normal checks run — never false. + Gate::before(function ($user, $ability) { + try { + return $user->hasPermissionTo('manage all') ? true : null; + } catch (\Throwable $e) { + return null; + } + }); } } diff --git a/resources/views/admin/users.blade.php b/resources/views/admin/users.blade.php index a8a3ea8..443d252 100644 --- a/resources/views/admin/users.blade.php +++ b/resources/views/admin/users.blade.php @@ -5,10 +5,15 @@ {{ __('Users') }} - - {{ __('New user') }} - - + + diff --git a/resources/views/livewire/role-permission-manager.blade.php b/resources/views/livewire/role-permission-manager.blade.php new file mode 100644 index 0000000..4a7efa5 --- /dev/null +++ b/resources/views/livewire/role-permission-manager.blade.php @@ -0,0 +1,87 @@ +
+ +
+

{{ __('Permission management') }}

+

{{ __('Tick which permissions each role has. Changes are saved instantly.') }}

+
+ + {{-- Crear rol / permiso --}} +
+
+
+ + +
+ @error('newRole') {{ $message }} @enderror +
+ +
+
+ + +
+ @error('newPermission') {{ $message }} @enderror +
+
+ + {{-- Matriz Roles × Permisos --}} +
+ + + + + @foreach($roles as $role) + + @endforeach + + + + @forelse($permissions as $perm) + + + @foreach($roles as $role) + + @endforeach + + @empty + + @endforelse + +
{{ __('Permission') }} +
+ {{ $role->name }} + @unless(in_array($role->name, ['Admin'], true)) + + @endunless +
+
+
+ {{ $perm->name }} + @if($perm->name !== 'manage all') + + @endif +
+
+ permissions->contains('id', $perm->id)) + wire:click="togglePermission({{ $role->id }}, '{{ $perm->name }}')" /> +
{{ __('No permissions') }}
+
+ +

+ {{ __('The Admin role and the "manage all" permission are protected and cannot be removed.') }} +

+
diff --git a/routes/web.php b/routes/web.php index 05fafb9..3fcf6fe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -136,6 +136,7 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa Route::get('/users/create', \App\Livewire\UserForm::class)->name('users.create'); Route::get('/users/{user}', \App\Livewire\UserView::class)->name('users.show'); Route::get('/users/{user}/edit', \App\Livewire\UserForm::class)->name('users.edit'); + Route::get('/permissions', \App\Livewire\RolePermissionManager::class)->name('permissions'); }); // Gestor de medios