From 938e704a67661882d5901efcb1ebf7fcf8f7d8c2 Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 17 Jun 2026 16:57:59 +0200 Subject: [PATCH] feat(roles): role CRUD screen (list + form name/description + view/edit/delete + bulk) Per request: - Migration: add nullable 'description' to the roles table. - RoleManager Livewire component + view at /admin/roles: * Roles list table with per-row checkboxes for bulk selection (+ select-all) and a 'Delete selected' bulk action (protected roles skipped). * 'New role' opens a modal form with just Name + Description (and permission checkboxes to assign). * Per-row View / Edit / Delete buttons (View modal shows description, counts and assigned permissions). - Admin role stays protected (no rename/delete/lose 'manage all'). - /admin/users links to the new Roles screen; the phase-1 permission matrix stays available via a 'Matrix view' link. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/RoleManager.php | 174 +++++++++++++++++ ..._170000_add_description_to_roles_table.php | 30 +++ resources/views/admin/users.blade.php | 4 +- .../views/livewire/role-manager.blade.php | 182 ++++++++++++++++++ routes/web.php | 1 + 5 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/RoleManager.php create mode 100644 database/migrations/2026_06_17_170000_add_description_to_roles_table.php create mode 100644 resources/views/livewire/role-manager.blade.php diff --git a/app/Livewire/RoleManager.php b/app/Livewire/RoleManager.php new file mode 100644 index 0000000..dd641e2 --- /dev/null +++ b/app/Livewire/RoleManager.php @@ -0,0 +1,174 @@ +can(self::CORE_PERMISSION), 403); + } + + private function flushCache(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function updatedSelectAll($value): void + { + $this->selected = $value + ? Role::pluck('id')->map(fn ($id) => (string) $id)->toArray() + : []; + } + + // ── Create / Edit ──────────────────────────────────────────────────────── + + public function openCreate(): void + { + $this->resetForm(); + $this->showForm = true; + } + + public function openEdit(int $id): void + { + $role = Role::with('permissions')->findOrFail($id); + $this->editingRole = $role->id; + $this->name = $role->name; + $this->description = $role->description ?? ''; + $this->rolePermissions = $role->permissions->pluck('name')->toArray(); + $this->resetErrorBag(); + $this->viewingRole = null; // close the view modal if open + $this->showForm = true; + } + + public function save(): void + { + $this->validate([ + 'name' => 'required|string|max:50|unique:roles,name' . ($this->editingRole ? ',' . $this->editingRole : ''), + 'description' => 'nullable|string|max:255', + ], [], ['name' => 'nombre', 'description' => 'descripción']); + + if ($this->editingRole) { + $role = Role::findOrFail($this->editingRole); + $isProtected = in_array($role->name, self::PROTECTED_ROLES, true); + + // Protected roles can't be renamed + if (! $isProtected) { + $role->name = $this->name; + } + $role->description = $this->description ?: null; + $role->save(); + + $perms = $this->rolePermissions; + // Admin always keeps the core permission + if ($role->name === 'Admin' && ! in_array(self::CORE_PERMISSION, $perms, true)) { + $perms[] = self::CORE_PERMISSION; + } + $role->syncPermissions($perms); + } else { + $role = Role::create([ + 'name' => $this->name, + 'description' => $this->description ?: null, + ]); + if (! empty($this->rolePermissions)) { + $role->syncPermissions($this->rolePermissions); + } + } + + $this->flushCache(); + $this->closeForm(); + $this->dispatch('notify', 'Rol guardado correctamente'); + } + + public function closeForm(): void + { + $this->showForm = false; + $this->resetForm(); + } + + private function resetForm(): void + { + $this->reset(['editingRole', 'name', 'description', 'rolePermissions']); + $this->resetErrorBag(); + } + + // ── View ───────────────────────────────────────────────────────────────── + + public function openView(int $id): void + { + $this->viewingRole = $id; + } + + public function closeView(): void + { + $this->viewingRole = null; + } + + // ── Delete (single / bulk) ───────────────────────────────────────────────── + + public function delete(int $id): void + { + $role = Role::findOrFail($id); + 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->selected = array_values(array_diff($this->selected, [(string) $id, $id])); + $this->flushCache(); + $this->dispatch('notify', 'Rol eliminado'); + } + + public function bulkDelete(): void + { + $roles = Role::whereIn('id', $this->selected)->get(); + $deleted = 0; + $skipped = 0; + foreach ($roles as $role) { + if (in_array($role->name, self::PROTECTED_ROLES, true)) { $skipped++; continue; } + $role->delete(); + $deleted++; + } + $this->selected = []; + $this->flushCache(); + $msg = "{$deleted} rol(es) eliminados"; + if ($skipped) $msg .= " ({$skipped} protegido(s) omitido(s))"; + $this->dispatch('notify', $msg); + } + + public function render() + { + return view('livewire.role-manager', [ + 'roles' => Role::with('permissions')->withCount('users')->orderBy('name')->get(), + 'permissions' => Permission::orderBy('name')->get(), + 'viewing' => $this->viewingRole + ? Role::with('permissions')->withCount('users')->find($this->viewingRole) + : null, + ]); + } +} diff --git a/database/migrations/2026_06_17_170000_add_description_to_roles_table.php b/database/migrations/2026_06_17_170000_add_description_to_roles_table.php new file mode 100644 index 0000000..bfdcf51 --- /dev/null +++ b/database/migrations/2026_06_17_170000_add_description_to_roles_table.php @@ -0,0 +1,30 @@ +getTable(), 'description')) { + $table->string('description')->nullable()->after('name'); + } + }); + } + + public function down(): void + { + $table = config('permission.table_names.roles', 'roles'); + + Schema::table($table, function (Blueprint $table) { + if (Schema::hasColumn($table->getTable(), 'description')) { + $table->dropColumn('description'); + } + }); + } +}; diff --git a/resources/views/admin/users.blade.php b/resources/views/admin/users.blade.php index 443d252..c9ac290 100644 --- a/resources/views/admin/users.blade.php +++ b/resources/views/admin/users.blade.php @@ -6,8 +6,8 @@
- - {{ __('Permissions') }} + + {{ __('Roles & permissions') }} {{ __('New user') }} diff --git a/resources/views/livewire/role-manager.blade.php b/resources/views/livewire/role-manager.blade.php new file mode 100644 index 0000000..aff05e2 --- /dev/null +++ b/resources/views/livewire/role-manager.blade.php @@ -0,0 +1,182 @@ +
+ + {{-- Cabecera --}} + + + {{-- Barra de acciones en grupo --}} + @if(count($selected) > 0) +
+ {{ count($selected) }} {{ __('selected') }} + +
+ @endif + + {{-- Tabla de roles --}} +
+ + + + + + + + + + + + + @forelse($roles as $role) + + + + + + + + + @empty + + @endforelse + +
+ + {{ __('Name') }}{{ __('Description') }}{{ __('Permissions') }}{{ __('Users') }}{{ __('Actions') }}
+ + + {{ $role->name }} + @if(in_array($role->name, ['Admin'], true)) + {{ __('protected') }} + @endif + {{ $role->description ?: '—' }} + {{ $role->permissions->count() }} + + {{ $role->users_count }} + +
+ + + @unless(in_array($role->name, ['Admin'], true)) + + @endunless +
+
{{ __('No roles') }}
+
+ + {{-- ════════════════ MODAL CREAR / EDITAR ════════════════ --}} + @if($showForm) + + @endif + + {{-- ════════════════ MODAL VER ════════════════ --}} + @if($viewing) + + @endif +
diff --git a/routes/web.php b/routes/web.php index 3fcf6fe..488b833 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('/roles', \App\Livewire\RoleManager::class)->name('roles'); Route::get('/permissions', \App\Livewire\RolePermissionManager::class)->name('permissions'); });