From 53a11ae0c74290a058d1308c213b8ac706ec20dd Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 26 Aug 2026 09:04:25 +0100 Subject: [PATCH] improvements --- Modules/Helpdesk/Livewire/KanbanBoard.php | 4 +- ...eate_user_management_roles_permissions.php | 106 +++++++++++ .../Http/Controllers/UserController.php | 170 +++++++++++++++--- Modules/UserManagement/Models/Permission.php | 25 +++ Modules/UserManagement/Models/Role.php | 38 ++++ Modules/UserManagement/Routes/web.php | 13 ++ app/Models/User.php | 25 +++ .../helpdesk/livewire/kanban-board.blade.php | 2 +- .../modules/user-management/create.blade.php | 13 ++ .../modules/user-management/edit.blade.php | 13 ++ .../user-management/permission-form.blade.php | 15 ++ .../user-management/role-form.blade.php | 20 +++ .../modules/user-management/roles.blade.php | 71 +++++++- 13 files changed, 479 insertions(+), 36 deletions(-) create mode 100644 Modules/UserManagement/Database/Migrations/2026_08_26_090000_create_user_management_roles_permissions.php create mode 100644 Modules/UserManagement/Models/Permission.php create mode 100644 Modules/UserManagement/Models/Role.php create mode 100644 resources/themes/backend/views/modules/user-management/permission-form.blade.php create mode 100644 resources/themes/backend/views/modules/user-management/role-form.blade.php diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index b81eaee..6dff560 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -206,7 +206,9 @@ final class KanbanBoard extends Component } return Ticket::query() - ->with(['user:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) + ->leftJoin('users as helpdesk_agents', 'helpdesk_agents.id', '=', 'helpdesk_tickets.agent_id') + ->select('helpdesk_tickets.*', 'helpdesk_agents.name as agent_name') + ->with(['user:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ->visibleTo(Auth::user()) ->where('board_id', $board->id) ->latest('last_activity_at') diff --git a/Modules/UserManagement/Database/Migrations/2026_08_26_090000_create_user_management_roles_permissions.php b/Modules/UserManagement/Database/Migrations/2026_08_26_090000_create_user_management_roles_permissions.php new file mode 100644 index 0000000..30848c0 --- /dev/null +++ b/Modules/UserManagement/Database/Migrations/2026_08_26_090000_create_user_management_roles_permissions.php @@ -0,0 +1,106 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_system')->default(false); + $table->timestamps(); + }); + + Schema::create('user_management_permissions', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('user_management_role_user', function (Blueprint $table): void { + $table->foreignId('role_id')->constrained('user_management_roles')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->primary(['role_id', 'user_id']); + }); + + Schema::create('user_management_permission_role', function (Blueprint $table): void { + $table->foreignId('permission_id')->constrained('user_management_permissions')->cascadeOnDelete(); + $table->foreignId('role_id')->constrained('user_management_roles')->cascadeOnDelete(); + $table->primary(['permission_id', 'role_id']); + }); + + $now = now(); + + $adminRole = DB::table('user_management_roles')->insertGetId([ + 'name' => 'Administrator', + 'slug' => 'administrator', + 'description' => 'Full application administration.', + 'is_system' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $agentRole = DB::table('user_management_roles')->insertGetId([ + 'name' => 'Helpdesk Agent', + 'slug' => 'helpdesk-agent', + 'description' => 'Helpdesk workflow and ticket operations.', + 'is_system' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + foreach ([ + ['Manage users', 'users.manage'], + ['Manage roles', 'roles.manage'], + ['Manage permissions', 'permissions.manage'], + ['Manage helpdesk', 'helpdesk.manage'], + ['Manage documentation', 'documentation.manage'], + ] as [$name, $slug]) { + $permissionId = DB::table('user_management_permissions')->insertGetId([ + 'name' => $name, + 'slug' => $slug, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('user_management_permission_role')->insert([ + 'permission_id' => $permissionId, + 'role_id' => $adminRole, + ]); + + if ($slug === 'helpdesk.manage') { + DB::table('user_management_permission_role')->insert([ + 'permission_id' => $permissionId, + 'role_id' => $agentRole, + ]); + } + } + + foreach (DB::table('users')->where('helpdesk_admin', true)->pluck('id') as $userId) { + DB::table('user_management_role_user')->insertOrIgnore(['role_id' => $adminRole, 'user_id' => $userId]); + } + + foreach (DB::table('users')->where('helpdesk_agent', true)->where('helpdesk_admin', false)->pluck('id') as $userId) { + DB::table('user_management_role_user')->insertOrIgnore(['role_id' => $agentRole, 'user_id' => $userId]); + } + } + + public function down(): void + { + Schema::dropIfExists('user_management_permission_role'); + Schema::dropIfExists('user_management_role_user'); + Schema::dropIfExists('user_management_permissions'); + Schema::dropIfExists('user_management_roles'); + } +}; diff --git a/Modules/UserManagement/Http/Controllers/UserController.php b/Modules/UserManagement/Http/Controllers/UserController.php index 254be62..8efccfd 100644 --- a/Modules/UserManagement/Http/Controllers/UserController.php +++ b/Modules/UserManagement/Http/Controllers/UserController.php @@ -10,7 +10,10 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Modules\UserManagement\Models\Permission; +use Modules\UserManagement\Models\Role; final class UserController extends Controller { @@ -19,11 +22,11 @@ final class UserController extends Controller $search = trim((string) $request->query('search', '')); $users = User::query() + ->with('roles') ->when($search !== '', static function ($query) use ($search): void { - $query->where(static function ($query) use ($search): void { - $query->where('name', 'like', '%'.$search.'%') - ->orWhere('email', 'like', '%'.$search.'%'); - }); + $query->where(fn ($query) => $query + ->where('name', 'like', '%'.$search.'%') + ->orWhere('email', 'like', '%'.$search.'%')); }) ->orderBy('name') ->paginate(20) @@ -34,18 +37,12 @@ final class UserController extends Controller public function create(): View { - return view('modules.user-management.create'); + return view('modules.user-management.create', ['roles' => Role::query()->orderBy('name')->get()]); } public function store(Request $request): RedirectResponse { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'max:255', 'unique:users,email'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], - 'helpdesk_agent' => ['sometimes', 'boolean'], - 'helpdesk_admin' => ['sometimes', 'boolean'], - ]); + $data = $this->validatedUser($request); $user = User::query()->create([ 'name' => $data['name'], @@ -56,24 +53,24 @@ final class UserController extends Controller 'helpdesk_admin' => $request->boolean('helpdesk_admin'), ]); - return redirect()->route('user-management.edit', $user) - ->with('status', 'User created successfully.'); + $user->roles()->sync($request->input('roles', [])); + + return redirect()->route('user-management.edit', $user)->with('status', 'User created successfully.'); } public function edit(User $user): View { - return view('modules.user-management.edit', compact('user')); + $user->load('roles'); + + return view('modules.user-management.edit', [ + 'user' => $user, + 'roles' => Role::query()->orderBy('name')->get(), + ]); } public function update(Request $request, User $user): RedirectResponse { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], - 'password' => ['nullable', 'string', 'min:8', 'confirmed'], - 'helpdesk_agent' => ['sometimes', 'boolean'], - 'helpdesk_admin' => ['sometimes', 'boolean'], - ]); + $data = $this->validatedUser($request, $user); $user->name = $data['name']; $user->email = $data['email']; @@ -85,6 +82,7 @@ final class UserController extends Controller } $user->save(); + $user->roles()->sync($request->input('roles', [])); return back()->with('status', 'User updated successfully.'); } @@ -104,12 +102,128 @@ final class UserController extends Controller public function roles(): View { - $counts = [ - 'admins' => User::query()->where('helpdesk_admin', true)->count(), - 'agents' => User::query()->where('helpdesk_agent', true)->count(), - 'users' => User::query()->count(), - ]; + return view('modules.user-management.roles', [ + 'roles' => Role::query()->withCount(['users', 'permissions'])->orderBy('name')->get(), + 'permissions' => Permission::query()->withCount('roles')->orderBy('name')->get(), + ]); + } - return view('modules.user-management.roles', compact('counts')); + public function createRole(): View + { + return view('modules.user-management.role-form', [ + 'role' => new Role(), + 'permissions' => Permission::query()->orderBy('name')->get(), + ]); + } + + public function storeRole(Request $request): RedirectResponse + { + $data = $this->validatedRole($request); + $role = Role::query()->create($data); + $role->permissions()->sync($request->input('permissions', [])); + + return redirect()->route('user-management.roles')->with('status', 'Role created.'); + } + + public function editRole(Role $role): View + { + $role->load('permissions'); + + return view('modules.user-management.role-form', [ + 'role' => $role, + 'permissions' => Permission::query()->orderBy('name')->get(), + ]); + } + + public function updateRole(Request $request, Role $role): RedirectResponse + { + $role->update($this->validatedRole($request, $role)); + $role->permissions()->sync($request->input('permissions', [])); + + return redirect()->route('user-management.roles')->with('status', 'Role updated.'); + } + + public function destroyRole(Role $role): RedirectResponse + { + if ($role->is_system) { + return back()->withErrors(['role' => 'System roles cannot be deleted.']); + } + + $role->delete(); + + return back()->with('status', 'Role deleted.'); + } + + public function createPermission(): View + { + return view('modules.user-management.permission-form', ['permission' => new Permission()]); + } + + public function storePermission(Request $request): RedirectResponse + { + Permission::query()->create($this->validatedPermission($request)); + + return redirect()->route('user-management.roles')->with('status', 'Permission created.'); + } + + public function editPermission(Permission $permission): View + { + return view('modules.user-management.permission-form', compact('permission')); + } + + public function updatePermission(Request $request, Permission $permission): RedirectResponse + { + $permission->update($this->validatedPermission($request, $permission)); + + return redirect()->route('user-management.roles')->with('status', 'Permission updated.'); + } + + public function destroyPermission(Permission $permission): RedirectResponse + { + $permission->delete(); + + return back()->with('status', 'Permission deleted.'); + } + + private function validatedUser(Request $request, ?User $user = null): array + { + return $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user?->id)], + 'password' => [$user ? 'nullable' : 'required', 'string', 'min:8', 'confirmed'], + 'helpdesk_agent' => ['sometimes', 'boolean'], + 'helpdesk_admin' => ['sometimes', 'boolean'], + 'roles' => ['array'], + 'roles.*' => ['integer', 'exists:user_management_roles,id'], + ]); + } + + private function validatedRole(Request $request, ?Role $role = null): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255', Rule::unique('user_management_roles', 'slug')->ignore($role?->id)], + 'description' => ['nullable', 'string', 'max:2000'], + 'permissions' => ['array'], + 'permissions.*' => ['integer', 'exists:user_management_permissions,id'], + ]); + + $data['slug'] = filled($data['slug'] ?? null) ? Str::slug($data['slug']) : Str::slug($data['name']); + unset($data['permissions']); + + return $data; + } + + private function validatedPermission(Request $request, ?Permission $permission = null): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255', Rule::unique('user_management_permissions', 'slug')->ignore($permission?->id)], + 'description' => ['nullable', 'string', 'max:2000'], + ]); + + $data['slug'] = filled($data['slug'] ?? null) ? Str::slug($data['slug'], '.') : Str::slug($data['name'], '.'); + + return $data; } } diff --git a/Modules/UserManagement/Models/Permission.php b/Modules/UserManagement/Models/Permission.php new file mode 100644 index 0000000..95001b6 --- /dev/null +++ b/Modules/UserManagement/Models/Permission.php @@ -0,0 +1,25 @@ +belongsToMany( + Role::class, + 'user_management_permission_role', + 'permission_id', + 'role_id' + ); + } +} diff --git a/Modules/UserManagement/Models/Role.php b/Modules/UserManagement/Models/Role.php new file mode 100644 index 0000000..3a6ea02 --- /dev/null +++ b/Modules/UserManagement/Models/Role.php @@ -0,0 +1,38 @@ + 'boolean']; + + public function permissions(): BelongsToMany + { + return $this->belongsToMany( + Permission::class, + 'user_management_permission_role', + 'role_id', + 'permission_id' + ); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany( + User::class, + 'user_management_role_user', + 'role_id', + 'user_id' + ); + } +} diff --git a/Modules/UserManagement/Routes/web.php b/Modules/UserManagement/Routes/web.php index 13ba516..29c2e65 100644 --- a/Modules/UserManagement/Routes/web.php +++ b/Modules/UserManagement/Routes/web.php @@ -8,7 +8,20 @@ use Modules\UserManagement\Http\Controllers\UserController; Route::get('/', [UserController::class, 'index'])->name('index'); Route::get('/create', [UserController::class, 'create'])->name('create'); Route::post('/', [UserController::class, 'store'])->name('store'); + Route::get('/roles', [UserController::class, 'roles'])->name('roles'); +Route::get('/roles/create', [UserController::class, 'createRole'])->name('roles.create'); +Route::post('/roles', [UserController::class, 'storeRole'])->name('roles.store'); +Route::get('/roles/{role}/edit', [UserController::class, 'editRole'])->name('roles.edit'); +Route::put('/roles/{role}', [UserController::class, 'updateRole'])->name('roles.update'); +Route::delete('/roles/{role}', [UserController::class, 'destroyRole'])->name('roles.destroy'); + +Route::get('/permissions/create', [UserController::class, 'createPermission'])->name('permissions.create'); +Route::post('/permissions', [UserController::class, 'storePermission'])->name('permissions.store'); +Route::get('/permissions/{permission}/edit', [UserController::class, 'editPermission'])->name('permissions.edit'); +Route::put('/permissions/{permission}', [UserController::class, 'updatePermission'])->name('permissions.update'); +Route::delete('/permissions/{permission}', [UserController::class, 'destroyPermission'])->name('permissions.destroy'); + Route::get('/{user}/edit', [UserController::class, 'edit'])->name('edit'); Route::put('/{user}', [UserController::class, 'update'])->name('update'); Route::delete('/{user}', [UserController::class, 'destroy'])->name('destroy'); diff --git a/app/Models/User.php b/app/Models/User.php index 0bfb92a..e2e6a48 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,6 +13,8 @@ use Illuminate\Support\Str; use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; +use Modules\UserManagement\Models\Role; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; /** * @property int $id @@ -48,6 +50,29 @@ class User extends Authenticatable implements PasskeyUser : $initials; } + + public function roles(): BelongsToMany + { + return $this->belongsToMany( + Role::class, + 'user_management_role_user', + 'user_id', + 'role_id' + ); + } + + public function hasRole(string $role): bool + { + return $this->roles()->where('slug', $role)->exists(); + } + + public function hasPermission(string $permission): bool + { + return $this->roles() + ->whereHas('permissions', static fn ($query) => $query->where('slug', $permission)) + ->exists(); + } + public function isHelpdeskAdmin(): bool { return (bool) $this->helpdesk_admin; diff --git a/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php b/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php index b10b0c3..56e67b4 100644 --- a/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php +++ b/resources/themes/backend/views/modules/helpdesk/livewire/kanban-board.blade.php @@ -138,7 +138,7 @@
{{ $ticket->owner?->name ?? 'Unknown' }}
{{ $ticket->agent?->name ?? 'Unassigned' }} + aria-hidden="true"> {{ $ticket->agent_name ?? 'Unassigned' }}
@if ($ticket->project)
helpdesk_admin ?? false))> AdministratorFull Helpdesk, settings and user administration.
+
+

Custom roles

+
+ @foreach($roles as $roleOption) + + @endforeach +
+
+
diff --git a/resources/themes/backend/views/modules/user-management/edit.blade.php b/resources/themes/backend/views/modules/user-management/edit.blade.php index 2108403..0b16db1 100644 --- a/resources/themes/backend/views/modules/user-management/edit.blade.php +++ b/resources/themes/backend/views/modules/user-management/edit.blade.php @@ -16,6 +16,19 @@ +
+

Custom roles

+
+ @foreach($roles as $roleOption) + + @endforeach +
+
+
@if(auth()->id() !== $user->id)
@csrf @method('DELETE')
@endif diff --git a/resources/themes/backend/views/modules/user-management/permission-form.blade.php b/resources/themes/backend/views/modules/user-management/permission-form.blade.php new file mode 100644 index 0000000..b551daa --- /dev/null +++ b/resources/themes/backend/views/modules/user-management/permission-form.blade.php @@ -0,0 +1,15 @@ + +
+
Administration

{{ $permission->exists ? 'Edit permission' : 'Create permission' }}

Define a reusable permission for application roles.

Back
+@if($errors->any())
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif +
+@csrf @if($permission->exists) @method('PUT') @endif +
+ + +
+ +
+
+
+
diff --git a/resources/themes/backend/views/modules/user-management/role-form.blade.php b/resources/themes/backend/views/modules/user-management/role-form.blade.php new file mode 100644 index 0000000..373df90 --- /dev/null +++ b/resources/themes/backend/views/modules/user-management/role-form.blade.php @@ -0,0 +1,20 @@ + +
+
Administration

{{ $role->exists ? 'Edit role' : 'Create role' }}

Define the role and the permissions it grants.

Back
+@if($errors->any())
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif +
+@csrf @if($role->exists) @method('PUT') @endif +
+ + +
+ +

Permissions

+@foreach($permissions as $permission) + +@endforeach +
+
+
+
+
diff --git a/resources/themes/backend/views/modules/user-management/roles.blade.php b/resources/themes/backend/views/modules/user-management/roles.blade.php index 1108b3c..3b40296 100644 --- a/resources/themes/backend/views/modules/user-management/roles.blade.php +++ b/resources/themes/backend/views/modules/user-management/roles.blade.php @@ -1,10 +1,69 @@
-
Administration

Roles & permissions

Helpdesk uses three clear access levels.

-
-

Administrator

{{ $counts['admins'] }}

Full tickets, settings, documentation and user management access.

-

Agent

{{ $counts['agents'] }}

Can work tickets, Kanban workflow and support operations.

-

User

{{ $counts['users'] }}

Standard authenticated requester access.

-
+
+
+
Administration
+

Roles & permissions

+

Create roles, define permissions and assign permissions to roles.

+
+ +
+ + @if(session('status'))
{{ session('status') }}
@endif + @if($errors->any())
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif + +
+

Roles

+ + + + @forelse($roles as $role) + + + + + + + + @empty + + @endforelse + +
NameSlugUsersPermissionsActions
{{ $role->name }}@if($role->is_system) System@endif{{ $role->slug }}{{ $role->users_count }}{{ $role->permissions_count }}
+ Edit + @unless($role->is_system) +
@csrf @method('DELETE') + +
+ @endunless +
No roles created.
+
+ +
+

Permissions

+ + + + @forelse($permissions as $permission) + + + + + + + @empty + + @endforelse + +
NameSlugRolesActions
{{ $permission->name }}{{ $permission->slug }}{{ $permission->roles_count }}
+ Edit +
@csrf @method('DELETE') + +
+
No permissions created.
+