= 2a19940635 Feat: implement role-based priority restrictions and permission manager
- Added `priority`-based access logic to restrict role and user management actions.
- Introduced `Permission Manager` UI and routes for permission access control.
- Updated `RoleService` and `AppRoleService` to include `visible` scope for priority filtering.
- Enhanced seeding to assign priorities to default roles and ensure proper hierarchy.
- Updated sidebar, routes, and data structure to handle new permission enums and resource segmentation.
- Improved UI components to dynamically restrict visibility and actions based on role priority.
2026-05-25 10:14:28 +00:00

380 lines
14 KiB
PHP

<?php
use App\Concerns\{Confirmation, HandlesOperations};
use App\Data\Ui\TableHeader;
use App\Models\User;
use App\Services\{UserService, RoleService};
use Livewire\Attributes\{Computed, Layout, Title};
use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{DataCollection, PaginatedDataCollection};
use App\Data\Role\{RoleData, RoleAppData};
use App\Livewire\Forms\AssignRolesForm;
use App\Data\Permissions\DeactivatedPermissionData;
new
#[Title('Users')]
class extends Component {
use HandlesOperations, Confirmation;
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) {
$user = User::findOrFail($userId);
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
$targetUserMinPriority = $user->roles()->min('priority') ?? 999999;
if ($targetUserMinPriority <= $userPriority) {
abort(403, 'Unauthorized to manage this user.');
}
$this->currentUserId = $userId;
$this->form->reset();
$this->searchRoles();
$this->form->roleIds = $user->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();
}
public function selectAllPermissions(): void
{
$allPermissionIds = [];
if ($this->roleWithPermission) {
foreach ($this->roleWithPermission->items() as $role) {
foreach ($role->permissions->items() as $permission) {
$allPermissionIds[] = $permission->id;
}
}
}
$this->form->permissions = array_values(array_unique($allPermissionIds));
}
public function revokeAllPermissions(): void
{
$this->form->permissions = [];
}
/**
* 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">
@php
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
@endphp
@foreach($row->roles as $role)
@if($role->priority > $userPriority)
<x-mary-badge class="badge-soft badge-neutral" :value="ucfirst($role->name)"/>
@endif
@endforeach
</div>
@endscope
@scope('cell_permissions', $row)
<div class="flex flex-wrap gap-1">
@php
$displayedPermissions = [];
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
@endphp
@foreach($row->roles as $role)
@if($role->priority > $userPriority && $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">
@php
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
@endphp
@foreach($row->roles as $role)
@if($role->priority > $userPriority && $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)
@php
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
$rowMinPriority = collect($row->roles)->min('priority') ?? 999999;
$isManageable = $rowMinPriority > $userPriority;
@endphp
@if($isManageable)
<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="requireConfirmation('delete', {{ $row->id }});"
spinner="requireConfirmation('delete', {{ $row->id }});"
variant="danger"
:tooltip="__('Delete user')"
class="btn-sm btn-circle btn-error btn-soft"
/>
</div>
@endif
@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 w-full">
<legend class="flex w-full items-center justify-between gap-4">
<p class="text-xs font-bold">Permissions</p>
<div class="flex items-center gap-2 font-bold">
<label class="flex cursor-pointer items-center gap-1 text-xs text-primary">
<button
type="button"
wire:click="selectAllPermissions()"
>
Select All
</button>
</label>
<label class="flex cursor-pointer items-center gap-1 text-xs text-error">
<button
type="button"
class="text-error"
wire:click="revokeAllPermissions()"
>
Revoke All
</button>
</label>
</div>
</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
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
</div>