Feat: assign users to role and show users on list

- Added DTOs (`AssignUsersToRoleData`, `RoleUserData`, `UserSelectData`) for user-role operations and UI data mapping.
- Updated `RoleService` and `AppRoleService` with methods for user-role assignment, detachment, retrieval, and search.
- Enhanced Blade views and Livewire components to support user-role management in "Roles and Apps."
- Adjusted `Role`, `User`, and related models to incorporate Spatie roles and permissions support.
- Improved UI with search and selection capabilities for assigning users to roles.
This commit is contained in:
= 2026-05-19 12:24:42 +00:00
parent c680c1ae6e
commit 5a593ffdb5
11 changed files with 226 additions and 36 deletions

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
final class AssignUsersToRoleData extends Data
{
public function __construct(
public int $roleId,
/** @var int[] */
public array $userIds
) {}
}

View File

@ -12,7 +12,9 @@ final class RoleData extends Data
public function __construct( public function __construct(
public int $id, public int $id,
public string $name, public string $name,
#[DataCollectionOf(class: RoleAppData::class), ] #[DataCollectionOf(class: RoleAppData::class)]
public ?DataCollection $apps = null public ?DataCollection $apps = null,
#[DataCollectionOf(class: RoleUserData::class)]
public ?DataCollection $users = null,
) {} ) {}
} }

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use App\Models\User;
use Spatie\LaravelData\Data;
final class RoleUserData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $initials = '',
) {}
/**
* Intercept the Eloquent model and map it to the DTO properties.
*/
public static function fromModel(User $user): self
{
return new self(
id: $user->id,
name: $user->name,
initials: $user->initials()
);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
/**
* Lightweight DTO for populating the user search/select dropdown.
* Only carries the fields the UI choices component needs.
*/
final class UserSelectData extends Data
{
public function __construct(
public int $id,
public string $name,
) {}
}

View File

@ -13,6 +13,7 @@
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password'])] #[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
@ -21,6 +22,7 @@ final class User extends Authenticatable
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
use HasRoles;
use Notifiable; use Notifiable;
use TwoFactorAuthenticatable; use TwoFactorAuthenticatable;

View File

@ -4,8 +4,8 @@
namespace App\Services; namespace App\Services;
use App\Data\Role\{AssignAppsToRoleData, RoleAppData}; use App\Data\Role\{AssignAppsToRoleData, AssignUsersToRoleData, RoleAppData, RoleUserData, UserSelectData};
use App\Models\{AppRole, ConnectedApp}; use App\Models\{AppRole, ConnectedApp, Role, User};
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
@ -105,7 +105,96 @@ public function searchAppsForSelect(string $search = ''): Collection
#[NoDiscard] #[NoDiscard]
public function getAllAppIds(): array public function getAllAppIds(): array
{ {
// Replace 'ConnectedApp' with your actual model name
return ConnectedApp::pluck('id')->toArray(); return ConnectedApp::pluck('id')->toArray();
} }
/**
* Assign users to a role using spatie/laravel-permission.
*
* @throws Throwable when an error occurs during the assignment.
*/
public function assignUsersToRole(AssignUsersToRoleData $data): void
{
if (empty($data->userIds)) {
return;
}
DB::transaction(function () use ($data): void {
$role = Role::query()->findOrFail($data->roleId);
$users = User::query()->whereIn('id', $data->userIds)->get();
/** @var User $user */
foreach ($users as $user) {
if (! $user->hasRole($role)) {
$user->assignRole($role);
}
}
});
}
/**
* Remove a user from a role.
*
* @throws InvalidArgumentException when userId is invalid.
* @throws Throwable when an error occurs during the removal.
*/
public function detachUserFromRole(int $roleId, int $userId): void
{
if ($userId <= 0) {
throw new InvalidArgumentException('Invalid user id');
}
DB::transaction(function () use ($roleId, $userId): void {
$role = Role::query()->findOrFail($roleId);
$user = User::query()->findOrFail($userId);
$user->removeRole($role);
});
}
/**
* Get all users assigned to a given role.
*
* @return DataCollection<int, RoleUserData>
*/
#[NoDiscard]
public function getUsersForRole(int $roleId): DataCollection
{
$role = Role::query()->findOrFail($roleId);
$users = User::query()
->select(['id', 'name', 'email'])
->role($role)
->orderBy('name')
->get();
return RoleUserData::collect(
$users->map(fn (User $user): array => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
])->all(),
DataCollection::class,
);
}
/**
* Search users for the select dropdown, returning only id and name.
*
* @return DataCollection<int, UserSelectData>
*/
#[NoDiscard]
public function searchUsersForSelect(string $search = ''): DataCollection
{
$users = User::query()
->select(['id', 'name'])
->when($search, function ($query, $search): void {
$query->where('name', 'like', '%'.$search.'%');
})
->limit(50)
->orderBy('name')
->get();
return UserSelectData::collect($users, DataCollection::class);
}
} }

View File

@ -55,7 +55,7 @@ public function delete(int $id): void
public function getAll(): PaginatedDataCollection public function getAll(): PaginatedDataCollection
{ {
$roles = Role::query() $roles = Role::query()
->with('apps') ->with(['apps:id,name', 'users:id,name'])
->orderBy('id') ->orderBy('id')
->paginate(); ->paginate();

View File

@ -58,8 +58,8 @@ public function navItems(): DataCollection
sub_menu: NavSubItemData::collect([ sub_menu: NavSubItemData::collect([
new NavSubItemData( new NavSubItemData(
label: 'Roles and Apps', label: 'Roles and Apps',
route: 'access-manager.roles-and-apps', route: 'access-manager.roles-and-apps.index',
active_pattern: 'access-manager.roles-and-apps', active_pattern: 'access-manager.roles-and-apps.*',
), ),
], DataCollection::class) ], DataCollection::class)
), ),

View File

@ -3,7 +3,7 @@
use App\Concerns\HandlesOperations; use App\Concerns\HandlesOperations;
use App\Livewire\Forms\CreateRoleForm; use App\Livewire\Forms\CreateRoleForm;
use App\Services\AppRoleService; use App\Services\AppRoleService;
use App\Data\Role\{AssignAppsToRoleData, CreateRoleData, RoleAppData}; use App\Data\Role\{AssignAppsToRoleData, CreateRoleData, RoleAppData, RoleUserData};
use App\Data\Ui\TableHeader; use App\Data\Ui\TableHeader;
use App\Services\RoleService; use App\Services\RoleService;
use Livewire\Attributes\{Computed, Layout, Title, Validate}; use Livewire\Attributes\{Computed, Layout, Title, Validate};
@ -37,7 +37,7 @@ public function mount(): void
$this->headers = TableHeader::collect([ $this->headers = TableHeader::collect([
new TableHeader(key: 'name', label: 'Name'), new TableHeader(key: 'name', label: 'Name'),
new TableHeader(key: 'apps', label: 'Apps'), new TableHeader(key: 'apps', label: 'Apps'),
new TableHeader(key: '', label: 'Users'), new TableHeader(key: 'users', label: 'Users'),
], DataCollection::class); ], DataCollection::class);
} }
@ -132,6 +132,17 @@ public function delete(int $roleId): void
@endforeach @endforeach
@endscope @endscope
@scope('cell_users', $row)
<div class="flex -space-x-2 overflow-hidden">
@foreach($row->users as $user)
@php /** @var RoleUserData $user */ @endphp
<x-mary-avatar :placeholder="$user->initials"
class="w-8! h-8! bg-blue-100 text-blue-600 border-2 border-white ring-0"
tooltip="{{ $user->name }}"/>
@endforeach
</div>
@endscope
@scope('actions', $row) @scope('actions', $row)
<div class="flex gap-1"> <div class="flex gap-1">
<x-mary-button <x-mary-button

View File

@ -1,7 +1,7 @@
<?php <?php
use App\Concerns\HandlesOperations; use App\Concerns\HandlesOperations;
use App\Data\Role\{AssignAppsToRoleData, RoleAppData}; use App\Data\Role\{AssignAppsToRoleData, AssignUsersToRoleData, RoleAppData};
use App\Data\Ui\TableHeader; use App\Data\Ui\TableHeader;
use App\Livewire\Forms\AssignAppToRoleForm; use App\Livewire\Forms\AssignAppToRoleForm;
use App\Models\Role; use App\Models\Role;
@ -37,7 +37,8 @@ class extends Component {
public bool $showAddUserModal = false; public bool $showAddUserModal = false;
public ?int $selectedUserId = null; /** @var int[] */
public array $selectedUserIds = [];
public array $usersSearchable = []; public array $usersSearchable = [];
@ -75,7 +76,6 @@ public function mount(int $id): void
$this->userHeaders = TableHeader::collect([ $this->userHeaders = TableHeader::collect([
new TableHeader(key: 'name', label: 'Name'), new TableHeader(key: 'name', label: 'Name'),
new TableHeader(key: 'email', label: 'Email'),
], DataCollection::class); ], DataCollection::class);
} }
@ -86,14 +86,12 @@ public function apps(): DataCollection
} }
/** /**
* TODO: Replace with a service method that returns user DTOs for this role. * Get users assigned to this role via spatie/laravel-permission.
*
* @return array<int, array<string, mixed>>
*/ */
#[Computed] #[Computed]
public function getRoleUsers(): array public function getRoleUsers(): DataCollection
{ {
return []; return $this->appRoleService->getUsersForRole($this->roleId);
} }
@ -208,24 +206,51 @@ public function detachApp(int $assignmentId): void
public function openAddUserModal(): void public function openAddUserModal(): void
{ {
$this->reset('selectedUserId'); $this->reset('selectedUserIds');
$this->resetErrorBag(); $this->resetErrorBag();
$this->searchUsers('');
$this->showAddUserModal = true; $this->showAddUserModal = true;
} }
public function searchUsers(string $value = ''): void public function searchUsers(string $value = ''): void
{ {
$this->usersSearchable = []; $this->usersSearchable = $this
->appRoleService
->searchUsersForSelect($value)
->toArray();
} }
public function assignUser(): void public function assignUser(): void
{ {
$this->success('User assignment not implemented yet.'); $this->validate([
'selectedUserIds' => 'required|array|min:1',
'selectedUserIds.*' => 'integer|exists:users,id',
]);
$this->attempt(
action: function (): void {
$this->appRoleService->assignUsersToRole(new AssignUsersToRoleData(
roleId: $this->roleId,
userIds: $this->selectedUserIds,
));
$this->reset('selectedUserIds');
$this->showAddUserModal = false;
unset($this->getRoleUsers);
},
successMessage: 'Users assigned successfully!',
);
} }
public function detachUser(int $userId): void public function detachUser(int $userId): void
{ {
$this->success('User removal not implemented yet.'); $this->attempt(
action: function () use ($userId): void {
$this->appRoleService->detachUserFromRole($this->roleId, $userId);
unset($this->getRoleUsers);
},
successMessage: 'User removed from role.',
);
} }
}; };
?> ?>
@ -235,7 +260,7 @@ public function detachUser(int $userId): void
<div> <div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<x-mary-button <x-mary-button
:link="route('access-manager.roles-and-apps')" :link="route('access-manager.roles-and-apps.index')"
wire:navigate wire:navigate
icon="lucide.arrow-left" icon="lucide.arrow-left"
class="btn-sm btn-ghost" class="btn-sm btn-ghost"
@ -248,7 +273,7 @@ class="btn-sm btn-ghost"
<div class="flex flex-col md:flex-row gap-6 w-full"> <div class="flex flex-col md:flex-row gap-6 w-full">
<x-shared.card title="Connected Apps" icon="heroicon-o-squares-2x2" class="pb-2 w-full h-min"> <x-shared.card title="Connected Apps" icon="lucide-blocks" class="pb-2 w-full h-min">
<x-slot:actions> <x-slot:actions>
<x-mary-button wire:click="openAddAppModal" icon="lucide.plus" class="btn-sm"> <x-mary-button wire:click="openAddAppModal" icon="lucide.plus" class="btn-sm">
Add App Add App
@ -292,26 +317,22 @@ class="btn-sm btn-error btn-soft btn-circle"
@endif @endif
</x-shared.card> </x-shared.card>
<x-shared.card title="Users" icon="heroicon-o-users" class="pb-2 w-full h-min"> <x-shared.card title="Users" icon="lucide-users-round" class="pb-2 w-full h-min">
<x-slot:actions> <x-slot:actions>
<x-mary-button wire:click="openAddUserModal" icon="lucide.plus" class="btn-sm"> <x-mary-button wire:click="openAddUserModal" icon="lucide.plus" class="btn-sm">
Add User Add User
</x-mary-button> </x-mary-button>
</x-slot:actions> </x-slot:actions>
@if (count($this->getRoleUsers)) @if ($this->getRoleUsers->count())
<x-mary-table <x-mary-table
:headers="$userHeaders->toArray()" :headers="$userHeaders->toArray()"
:rows="$this->getRoleUsers" :rows="$this->getRoleUsers->toArray()"
> >
@scope('cell_name', $row) @scope('cell_name', $row)
<span class="font-medium text-gray-900">{{ $row['name'] }}</span> <span class="font-medium text-gray-900">{{ $row['name'] }}</span>
@endscope @endscope
@scope('cell_email', $row)
{{ $row['email'] }}
@endscope
@scope('actions', $row) @scope('actions', $row)
<x-mary-button <x-mary-button
icon="lucide.trash" icon="lucide.trash"
@ -396,7 +417,7 @@ class="bg-gray-50 text-gray-500 cursor-not-allowed"
<x-mary-modal wire:model="showAddUserModal" title="Add User to {{ $roleName }}"> <x-mary-modal wire:model="showAddUserModal" title="Add User to {{ $roleName }}">
<x-mary-form wire:submit="assignUser"> <x-mary-form wire:submit="assignUser">
<x-mary-choices <x-mary-choices
wire:model="selectedUserId" wire:model="selectedUserIds"
:options="$usersSearchable" :options="$usersSearchable"
search-function="searchUsers" search-function="searchUsers"
option-label="name" option-label="name"
@ -404,8 +425,7 @@ class="bg-gray-50 text-gray-500 cursor-not-allowed"
no-result-text="No users found." no-result-text="No users found."
placeholder="Search users..." placeholder="Search users..."
searchable searchable
single label="Users"
label="User"
/> />
<x-slot:actions> <x-slot:actions>

View File

@ -10,8 +10,10 @@
Route::livewire('dashboard', 'pages::dashboard')->name('dashboard'); Route::livewire('dashboard', 'pages::dashboard')->name('dashboard');
Route::prefix('access-manager')->name('access-manager.')->group(function (): void { Route::prefix('access-manager')->name('access-manager.')->group(function (): void {
Route::livewire('roles-and-apps', 'pages::access-manager.roles-and-apps.index')->name('roles-and-apps'); Route::prefix('roles-and-apps')->name('roles-and-apps.')->group(function (): void {
Route::livewire('roles-and-apps/{id}', 'pages::access-manager.roles-and-apps.show')->name('roles-and-apps.show'); Route::livewire('roles-and-apps', 'pages::access-manager.roles-and-apps.index')->name('index');
Route::livewire('roles-and-apps/{id}', 'pages::access-manager.roles-and-apps.show')->name('show');
});
}); });
Route::prefix('apps')->name('apps.')->group(function (): void { Route::prefix('apps')->name('apps.')->group(function (): void {