Feat: add user access management components

- Added `RestrictedUserPermission` migration and model for managing user-role-permission restrictions.
- Introduced `UserService`, `RoleService`, and related DTOs for handling user access, role assignments, and permission restrictions.
- Created new Livewire components and Blade views for user management, including role/permission assignment modals.
- Updated navigation and routing to include the "Users" section under "Access Manager."
- Enhanced `RoleService` with methods for retrieving permissions and managing role-user interactions.
- Improved UI with permission toggles and streamlined selection features for better user-role management.
This commit is contained in:
= 2026-05-20 13:07:28 +00:00
parent 562b4a220d
commit 07d1b235cc
14 changed files with 664 additions and 4 deletions

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Data\Permissions;
use Spatie\LaravelData\Data;
final class DeactivatedPermissionData extends Data
{
public function __construct(
public int $roleId,
public int $permissionId,
) {}
}

View File

@ -4,6 +4,7 @@
namespace App\Data\Role;
use App\Data\Permissions\PermissionData;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
@ -16,5 +17,7 @@ public function __construct(
public ?DataCollection $apps = null,
#[DataCollectionOf(class: RoleUserData::class)]
public ?DataCollection $users = null,
#[DataCollectionOf(PermissionData::class)]
public ?DataCollection $permissions = null,
) {}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Data\User;
use App\Data\Role\RoleData;
use App\Models\User;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
final class UserIndexData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public string $initials,
#[DataCollectionOf(class: RoleData::class)]
public ?DataCollection $roles = null,
public ?array $restrictedPermissionIds = null,
) {}
public static function fromModel(User $user)
{
return new self(
id: $user->id,
name: $user->name,
email: $user->email,
initials: $user->initials(),
roles: RoleData::collect($user->roles, DataCollection::class),
restrictedPermissionIds: $user->restrictedPermissions->pluck('id')->toArray()
);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
use Livewire\Form;
class AssignRolesForm extends Form
{
/**
* Holds the roles that the user has
*
* @var array <int, int>
*/
#[Validate('required|array')]
public array $roleIds = [];
/**
* Holds the direct permissions toggled for the user (from all selected roles).
*
* @var array<int, int>
*/
#[Validate('nullable|array')]
public array $permissions = [];
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, Pivot};
use Spatie\Permission\Models\Permission;
#[Fillable(['user_id', 'role_id', 'permission_id'])]
class RestrictedUserPermission extends Pivot
{
protected $table = 'restricted_user_permissions';
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
/**
* @return BelongsTo<Permission, $this>
*/
public function permission(): BelongsTo
{
return $this->belongsTo(Permission::class);
}
}

View File

@ -26,6 +26,16 @@ final class User extends Authenticatable
use Notifiable;
use TwoFactorAuthenticatable;
public function restrictedPermissions(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(
\Spatie\Permission\Models\Permission::class,
'restricted_user_permissions',
'user_id',
'permission_id'
)->withPivot('role_id')->withTimestamps();
}
/**
* Get the user's initials
*/

View File

@ -6,10 +6,11 @@
use App\Data\Role\{CreateRoleData, RoleData};
use App\Models\Role;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use NoDiscard;
use Spatie\LaravelData\PaginatedDataCollection;
use Spatie\LaravelData\{DataCollection, PaginatedDataCollection};
use Throwable;
final class RoleService
@ -20,7 +21,6 @@ final class RoleService
*
* @throws Throwable when an error occurs during the creation.
*/
#[NoDiscard]
public function create(CreateRoleData $data): RoleData
{
$role = DB::transaction(fn () => Role::query()->create([
@ -61,4 +61,40 @@ public function getAll(): PaginatedDataCollection
return RoleData::collect($roles, PaginatedDataCollection::class);
}
public function searchRolesForSelect(string $search = ''): Collection
{
return Role::query()
->when('' !== $search, fn ($query) => $query->where('name', 'like', "%{$search}%"))
->select('id', 'name')
->get();
}
public function getAllRoleIds(): array
{
return Role::query()->pluck('id')->toArray();
}
/**
* Return all permissions for the given roles, grouped by role name.
* Each permission is a plain array so Livewire can serialise the property cleanly.
*
* @param array<int, int> $roleIds
*
* @return DataCollection<int, RoleData>|null
*/
#[NoDiscard]
public function getPermissionsGroupedByRole(array $roleIds): ?DataCollection
{
if (empty($roleIds)) {
return null;
}
$roles = Role::query()
->with('permissions:id,name')
->whereIn('id', $roleIds)
->get(['id', 'name']);
return RoleData::collect($roles, DataCollection::class);
}
}

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Data\Permissions\DeactivatedPermissionData;
use App\Data\User\UserIndexData;
use App\Models\{RestrictedUserPermission, User};
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use NoDiscard;
use Spatie\LaravelData\{DataCollection, PaginatedDataCollection};
use Throwable;
final class UserService
{
/**
* Returns a Paginated Collection of users with their roles and connected apps DTO
*
* @return PaginatedDataCollection<int, UserIndexData>
*/
#[NoDiscard]
public function getAll(): PaginatedDataCollection
{
$users = User::query()
->with([
'roles:id,name',
'roles.apps:id,name',
'roles.permissions:id,name',
'restrictedPermissions',
])
->orderBy('id')
->paginate();
return UserIndexData::collect($users, PaginatedDataCollection::class);
}
/**
* Get the restricted permission IDs for a user.
*
* @return array<int>
*/
public function getRestrictedPermissionIds(int $userId): array
{
if ($userId <= 0) {
return [];
}
return RestrictedUserPermission::query()
->where('user_id', $userId)
->pluck('permission_id')
->toArray();
}
/**
* Assign roles to a user.
*/
public function assignRoles(int $userId, array $roleIds): void
{
if ($userId <= 0) {
throw new InvalidArgumentException('Invalid id');
}
DB::transaction(function () use ($userId, $roleIds): void {
$user = User::query()->findOrFail($userId);
$user->syncRoles($roleIds);
});
}
/**
* Sync restricted permissions for a user.
*
* @param DataCollection<int, DeactivatedPermissionData> $deactivatedPermissions
*
* @throws Throwable
*/
public function syncRestrictedPermissions(int $userId, DataCollection $deactivatedPermissions): void
{
if ($userId <= 0) {
throw new InvalidArgumentException('Invalid user ID');
}
$payload = $deactivatedPermissions->toCollection()->map(fn ($data) => [
'user_id' => $userId,
'role_id' => $data->roleId,
'permission_id' => $data->permissionId,
])->toArray();
DB::transaction(function () use ($userId, $payload): void {
RestrictedUserPermission::query()
->where('user_id', $userId)
->delete();
if (! empty($payload)) {
RestrictedUserPermission::query()->insert($payload);
}
});
}
/**
* @throws InvalidArgumentException when id is invalid.
* @throws Throwable when an error occurs during the deletion.
*/
public function delete(int $id): void
{
if ($id <= 0) {
throw new InvalidArgumentException('Invalid id');
}
DB::transaction(function () use ($id): void {
User::query()->findOrFail($id)->delete();
});
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\{Role, User};
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Permission;
return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('restricted_user_permissions', function (Blueprint $table): void {
$table->id();
$table->foreignIdFor(Role::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(Permission::class)->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['role_id', 'user_id', 'permission_id'], 'restricted_role_user_permission_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('restricted_user_permissions');
}
};

View File

@ -61,6 +61,11 @@ public function navItems(): DataCollection
route: 'access-manager.roles-and-apps.index',
active_pattern: 'access-manager.roles-and-apps.*',
),
new NavSubItemData(
label: 'Users',
route: 'access-manager.users.index',
active_pattern: 'access-manager.users.*',
),
], DataCollection::class)
),

View File

@ -1,6 +1,5 @@
@props([
'label' => 'Select Items',
'model', // Livewire property
'options', // The data collection passed from the parent
'searchFunction', // The parent method to call for searching
'selectAllAction', // The parent method to call for selecting all
@ -24,7 +23,7 @@
</legend>
<x-mary-choices
wire:model="{{ $model }}"
{{ $attributes->whereStartsWith('wire:model') }}
:options="$options"
search-function="{{ $searchFunction }}"
option-label="name"

View File

@ -0,0 +1,319 @@
<?php
use App\Concerns\HandlesOperations;
use App\Data\Ui\TableHeader;
use App\Models\User;
use App\Services\UserService;
use Livewire\Attributes\{Computed, Layout, Title};
use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\PaginatedDataCollection;
use App\Data\Role\RoleData;
use App\Data\Role\RoleAppData;
use App\Livewire\Forms\AssignRolesForm;
use App\Services\RoleService;
use App\Data\Permissions\DeactivatedPermissionData;
new
#[Title('Users')]
class extends Component {
use HandlesOperations;
protected UserService $userService;
protected RoleService $roleService;
public AssignRolesForm $form;
public bool $showAssignRolesModal = false;
public int $currentUserId = 0;
public array $rolesSearchable = [];
#[DataCollectionOf(RoleData::class)]
public ?DataCollection $roleWithPermission = null;
#[DataCollectionOf(TableHeader::class)]
public DataCollection $headers;
public function boot(UserService $userService, RoleService $roleService): void
{
$this->userService = $userService;
$this->roleService = $roleService;
}
public function mount(): void
{
$this->headers = TableHeader::collect([
new TableHeader(key: 'name', label: 'User'),
new TableHeader(key: 'roles', label: 'Roles'),
new TableHeader(key: 'permissions', label: 'Permissions'),
new TableHeader(key: 'apps', label: 'Connected Apps'),
], DataCollection::class);
}
#[Computed]
public function getUsers(): PaginatedDataCollection
{
return $this->userService->getAll();
}
public function delete(int $userId): void
{
$this->attempt(
action: function () use ($userId): void {
$this->userService->delete($userId);
unset($this->getUsers);
},
successMessage: 'User deleted successfully!',
);
}
public function openAssignRolesModal(int $userId): void
{
$this->attempt(
action: function () use ($userId) {
$this->currentUserId = $userId;
$this->form->reset();
$this->searchRoles();
$this->form->roleIds = User::findOrFail($userId)->roles()->pluck('id')->toArray();
$this->hydratePermissionInForm();
$this->showAssignRolesModal = true;
},
showSuccess: false
);
}
private function hydratePermissionInForm(): void
{
$this->roleWithPermission = $this->roleService->getPermissionsGroupedByRole($this->form->roleIds);
$allPermissionIds = [];
if ($this->roleWithPermission) {
foreach ($this->roleWithPermission->items() as $role) {
foreach ($role->permissions->items() as $permission) {
$allPermissionIds[] = $permission->id;
}
}
}
$restrictedIds = $this->userService->getRestrictedPermissionIds($this->currentUserId);
$this->form->permissions = array_values(array_diff($allPermissionIds, $restrictedIds));
}
/**
* Refresh the permission list whenever the roles selection changes.
* This method is automatically hooked by livewire cause of naming convention.
*/
public function updatedFormRoleIds(): void
{
$this->hydratePermissionInForm();
}
public function searchRoles(string $value = ''): void
{
$this->rolesSearchable = $this->roleService->searchRolesForSelect($value)->toArray();
}
public function selectAllRoles(): void
{
$this->form->roleIds = $this->roleService->getAllRoleIds();
$this->searchRoles();
$this->hydratePermissionInForm();
}
public function clearRoles(): void
{
$this->form->roleIds = [];
$this->hydratePermissionInForm();
}
/**
* Get the deactivated permissions represented as DTOs containing the role ID and permission ID.
*
* @return DataCollection<int, DeactivatedPermissionData>
*/
public function getDeactivatedPermissions(): DataCollection
{
$deactivated = [];
$activePermissions = array_map('intval', $this->form->permissions);
if ($this->roleWithPermission) {
foreach ($this->roleWithPermission->items() as $role) {
foreach ($role->permissions->items() as $permission) {
if (!in_array($permission->id, $activePermissions, true)) {
$deactivated[] = new DeactivatedPermissionData(
roleId: $role->id,
permissionId: $permission->id
);
}
}
}
}
return DeactivatedPermissionData::collect($deactivated, DataCollection::class);
}
public function saveRoles(): void
{
$this->attempt(
action: function () {
$this->form->validate();
$deactivatedPermissions = $this->getDeactivatedPermissions();
$this->userService->assignRoles($this->currentUserId, $this->form->roleIds);
$this->userService->syncRestrictedPermissions($this->currentUserId, $deactivatedPermissions);
$this->showAssignRolesModal = false;
$this->roleWithPermission = null;
$this->form->reset();
unset($this->getUsers);
},
onError: function () {
$this->showAssignRolesModal = false;
$this->roleWithPermission = null;
$this->form->reset();
},
successMessage: 'Roles updated successfully!'
);
}
};
?>
<div>
<x-shared.card title="Users" icon="lucide-users" class="pb-2">
<x-mary-table
:headers="$headers->toArray()"
:rows="$this->getUsers->items()"
>
@scope('cell_name', $row)
<div class="flex items-center gap-3">
<x-mary-avatar :placeholder="$row->initials" class="bg-blue-100 text-blue-600"/>
<div>
<div class="font-medium text-gray-900">{{ $row->name }}</div>
<div class="text-sm text-gray-500">{{ $row->email }}</div>
</div>
</div>
@endscope
@scope('cell_roles', $row)
<div class="flex flex-wrap gap-1">
@foreach($row->roles as $role)
<x-mary-badge class="badge-soft badge-neutral" :value="$role->name"/>
@endforeach
</div>
@endscope
@scope('cell_permissions', $row)
<div class="flex flex-wrap gap-1">
@php
$displayedPermissions = [];
@endphp
@foreach($row->roles as $role)
@if($role->permissions)
@foreach($role->permissions as $permission)
@if(!in_array($permission->id, $displayedPermissions))
@php
$displayedPermissions[] = $permission->id;
$isRestricted = in_array($permission->id, $row->restrictedPermissionIds ?? []);
@endphp
@if($isRestricted)
<x-mary-badge class="bg-base-300 text-base-content/50 line-through"
:value="$permission->name"/>
@else
<x-mary-badge class="badge-soft badge-primary" :value="$permission->name"/>
@endif
@endif
@endforeach
@endif
@endforeach
</div>
@endscope
@scope('cell_apps', $row)
<div class="flex flex-wrap gap-1">
@foreach($row->roles as $role)
@if($role->apps)
@foreach ($role->apps as $app)
<x-mary-badge class="badge-soft badge-secondary" :value="$app->appName"/>
@endforeach
@endif
@endforeach
</div>
@endscope
@scope('actions', $row)
<div class="flex gap-1">
<x-mary-button
icon="lucide.shield-plus"
wire:click="openAssignRolesModal({{ $row->id }})"
tooltip="Manage roles"
class="btn-sm btn-circle btn-soft"
spinner="openAssignRolesModal({{ $row->id }})"
/>
<x-mary-button
icon="lucide.trash"
wire:click="delete({{ $row->id }})"
spinner="delete({{ $row->id }})"
variant="danger"
:tooltip="__('Delete user')"
class="btn-sm btn-circle btn-error btn-soft"
/>
</div>
@endscope
</x-mary-table>
</x-shared.card>
<x-mary-modal wire:model="showAssignRolesModal" title="Assign Roles">
<x-mary-form wire:submit="saveRoles">
<x-shared.choices
wire:model.live="form.roleIds"
:options="$rolesSearchable"
search-function="searchRoles"
select-all-action="selectAllRoles"
clear-action="clearRoles"
label="Roles"
/>
@if ($roleWithPermission)
<fieldset class="mt-5 space-y-4">
<legend class="flex items-center gap-2">
<label class="text-xs font-bold">Permissions</label>
</legend>
@foreach ($roleWithPermission?->items() as $role)
<div class="rounded-xl border border-base-300 bg-base-200/50 p-4">
<div class="mb-3 flex items-center gap-2">
<span
class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary ring-1 ring-inset ring-primary/20">
{{ $role->name }}
</span>
<span
class="text-xs text-base-content/50">{{$role->permissions->count()}} {{ $role->permissions->count() >= 2 ? 'Permissions' : 'Permission' }}</span>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
@foreach ($role->permissions as $permission)
<x-mary-toggle
class="toggle-primary"
:label="$permission->name" wire:model="form.permissions"
:value="$permission->id"
/>
@endforeach
</div>
</div>
@endforeach
</fieldset>
@endif
<x-slot:actions>
<x-mary-button @click="$wire.showAssignRolesModal = false" class="btn-ghost">
Cancel
</x-mary-button>
<x-mary-button type="submit" spinner="saveRoles" class="btn-primary">
Save Roles
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
</div>

View File

@ -14,6 +14,10 @@
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('users')->name('users.')->group(function (): void {
Route::livewire('users', 'pages::access-manager.users.index')->name('index');
});
});
Route::prefix('apps')->name('apps.')->group(function (): void {

16
tests/ArchTest.php Normal file
View File

@ -0,0 +1,16 @@
<?php
arch('it does not use debug functions')
->expect(['ds', 'dsd', 'dsq', 'dd', 'ddd', 'dump', 'ray', 'die', 'var_dump', 'sleep', 'exit'])
->not->toBeUsed();
arch('application uses strict types')
->expect('App')
->toUseStrictTypes();
arch('controllers have exact suffix')
->expect('App\Http\Controllers')
->toHaveSuffix('Controller');
arch('models do not use http helpers')
->expect(['request', 'session', 'cookie'])
->not->toBeUsedIn('App\Models');