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:
parent
c680c1ae6e
commit
5a593ffdb5
16
app/Data/Role/AssignUsersToRoleData.php
Normal file
16
app/Data/Role/AssignUsersToRoleData.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
@ -12,7 +12,9 @@ final class RoleData extends Data
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
#[DataCollectionOf(class: RoleAppData::class), ]
|
||||
public ?DataCollection $apps = null
|
||||
#[DataCollectionOf(class: RoleAppData::class)]
|
||||
public ?DataCollection $apps = null,
|
||||
#[DataCollectionOf(class: RoleUserData::class)]
|
||||
public ?DataCollection $users = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
29
app/Data/Role/RoleUserData.php
Normal file
29
app/Data/Role/RoleUserData.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
19
app/Data/Role/UserSelectData.php
Normal file
19
app/Data/Role/UserSelectData.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[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, SoftDeletes;
|
||||
|
||||
use HasRoles;
|
||||
use Notifiable;
|
||||
use TwoFactorAuthenticatable;
|
||||
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\Role\{AssignAppsToRoleData, RoleAppData};
|
||||
use App\Models\{AppRole, ConnectedApp};
|
||||
use App\Data\Role\{AssignAppsToRoleData, AssignUsersToRoleData, RoleAppData, RoleUserData, UserSelectData};
|
||||
use App\Models\{AppRole, ConnectedApp, Role, User};
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
@ -105,7 +105,96 @@ public function searchAppsForSelect(string $search = ''): Collection
|
||||
#[NoDiscard]
|
||||
public function getAllAppIds(): array
|
||||
{
|
||||
// Replace 'ConnectedApp' with your actual model name
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ public function delete(int $id): void
|
||||
public function getAll(): PaginatedDataCollection
|
||||
{
|
||||
$roles = Role::query()
|
||||
->with('apps')
|
||||
->with(['apps:id,name', 'users:id,name'])
|
||||
->orderBy('id')
|
||||
->paginate();
|
||||
|
||||
|
||||
@ -58,8 +58,8 @@ public function navItems(): DataCollection
|
||||
sub_menu: NavSubItemData::collect([
|
||||
new NavSubItemData(
|
||||
label: 'Roles and Apps',
|
||||
route: 'access-manager.roles-and-apps',
|
||||
active_pattern: 'access-manager.roles-and-apps',
|
||||
route: 'access-manager.roles-and-apps.index',
|
||||
active_pattern: 'access-manager.roles-and-apps.*',
|
||||
),
|
||||
], DataCollection::class)
|
||||
),
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
use App\Concerns\HandlesOperations;
|
||||
use App\Livewire\Forms\CreateRoleForm;
|
||||
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\Services\RoleService;
|
||||
use Livewire\Attributes\{Computed, Layout, Title, Validate};
|
||||
@ -37,7 +37,7 @@ public function mount(): void
|
||||
$this->headers = TableHeader::collect([
|
||||
new TableHeader(key: 'name', label: 'Name'),
|
||||
new TableHeader(key: 'apps', label: 'Apps'),
|
||||
new TableHeader(key: '', label: 'Users'),
|
||||
new TableHeader(key: 'users', label: 'Users'),
|
||||
], DataCollection::class);
|
||||
}
|
||||
|
||||
@ -132,6 +132,17 @@ public function delete(int $roleId): void
|
||||
@endforeach
|
||||
@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)
|
||||
<div class="flex gap-1">
|
||||
<x-mary-button
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Concerns\HandlesOperations;
|
||||
use App\Data\Role\{AssignAppsToRoleData, RoleAppData};
|
||||
use App\Data\Role\{AssignAppsToRoleData, AssignUsersToRoleData, RoleAppData};
|
||||
use App\Data\Ui\TableHeader;
|
||||
use App\Livewire\Forms\AssignAppToRoleForm;
|
||||
use App\Models\Role;
|
||||
@ -37,7 +37,8 @@ class extends Component {
|
||||
|
||||
public bool $showAddUserModal = false;
|
||||
|
||||
public ?int $selectedUserId = null;
|
||||
/** @var int[] */
|
||||
public array $selectedUserIds = [];
|
||||
|
||||
public array $usersSearchable = [];
|
||||
|
||||
@ -75,7 +76,6 @@ public function mount(int $id): void
|
||||
|
||||
$this->userHeaders = TableHeader::collect([
|
||||
new TableHeader(key: 'name', label: 'Name'),
|
||||
new TableHeader(key: 'email', label: 'Email'),
|
||||
], DataCollection::class);
|
||||
}
|
||||
|
||||
@ -86,14 +86,12 @@ public function apps(): DataCollection
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Replace with a service method that returns user DTOs for this role.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
* Get users assigned to this role via spatie/laravel-permission.
|
||||
*/
|
||||
#[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
|
||||
{
|
||||
$this->reset('selectedUserId');
|
||||
$this->reset('selectedUserIds');
|
||||
$this->resetErrorBag();
|
||||
$this->searchUsers('');
|
||||
$this->showAddUserModal = true;
|
||||
}
|
||||
|
||||
public function searchUsers(string $value = ''): void
|
||||
{
|
||||
$this->usersSearchable = [];
|
||||
$this->usersSearchable = $this
|
||||
->appRoleService
|
||||
->searchUsersForSelect($value)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$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 class="flex items-center gap-3">
|
||||
<x-mary-button
|
||||
:link="route('access-manager.roles-and-apps')"
|
||||
:link="route('access-manager.roles-and-apps.index')"
|
||||
wire:navigate
|
||||
icon="lucide.arrow-left"
|
||||
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">
|
||||
|
||||
<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-mary-button wire:click="openAddAppModal" icon="lucide.plus" class="btn-sm">
|
||||
Add App
|
||||
@ -292,26 +317,22 @@ class="btn-sm btn-error btn-soft btn-circle"
|
||||
@endif
|
||||
</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-mary-button wire:click="openAddUserModal" icon="lucide.plus" class="btn-sm">
|
||||
Add User
|
||||
</x-mary-button>
|
||||
</x-slot:actions>
|
||||
|
||||
@if (count($this->getRoleUsers))
|
||||
@if ($this->getRoleUsers->count())
|
||||
<x-mary-table
|
||||
:headers="$userHeaders->toArray()"
|
||||
:rows="$this->getRoleUsers"
|
||||
:rows="$this->getRoleUsers->toArray()"
|
||||
>
|
||||
@scope('cell_name', $row)
|
||||
<span class="font-medium text-gray-900">{{ $row['name'] }}</span>
|
||||
@endscope
|
||||
|
||||
@scope('cell_email', $row)
|
||||
{{ $row['email'] }}
|
||||
@endscope
|
||||
|
||||
@scope('actions', $row)
|
||||
<x-mary-button
|
||||
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-form wire:submit="assignUser">
|
||||
<x-mary-choices
|
||||
wire:model="selectedUserId"
|
||||
wire:model="selectedUserIds"
|
||||
:options="$usersSearchable"
|
||||
search-function="searchUsers"
|
||||
option-label="name"
|
||||
@ -404,8 +425,7 @@ class="bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
no-result-text="No users found."
|
||||
placeholder="Search users..."
|
||||
searchable
|
||||
single
|
||||
label="User"
|
||||
label="Users"
|
||||
/>
|
||||
|
||||
<x-slot:actions>
|
||||
|
||||
@ -10,8 +10,10 @@
|
||||
Route::livewire('dashboard', 'pages::dashboard')->name('dashboard');
|
||||
|
||||
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::livewire('roles-and-apps/{id}', 'pages::access-manager.roles-and-apps.show')->name('roles-and-apps.show');
|
||||
Route::prefix('roles-and-apps')->name('roles-and-apps.')->group(function (): void {
|
||||
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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user