= 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

529 lines
18 KiB
PHP

<?php
use App\Concerns\{Confirmation, HandlesOperations};
use App\Data\Role\{AssignAppsToRoleData, AssignUsersToRoleData, RoleAppData, UpdateRolePriorityData};
use App\Data\Ui\TableHeader;
use App\Enums\Permissions\RoleAndAppsPermissionEnum;
use App\Livewire\Forms\AssignAppToRoleForm;
use App\Models\Role;
use App\Services\{AppRoleService, RoleService};
use Livewire\Attributes\{Computed, Layout, Title};
use Illuminate\Support\Carbon;
use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
/**
* @property-read DataCollection $apps
*/
new
#[Layout('layouts.app.sidebar')]
#[Title('Role Details')]
class extends Component {
use HandlesOperations, Confirmation;
public int $roleId = 0;
public string $roleName = '';
public int $rolePriority = 0;
public bool $showEditPriorityModal = false;
public ?int $newPriority = null;
public bool $showAddAppModal = false;
// UI State for Editing
public bool $isEditingApp = false;
public ?int $editingAssignmentId = null;
public string $editingAppName = '';
public AssignAppToRoleForm $assignForm;
public array $appsSearchable = [];
public bool $showAddUserModal = false;
/** @var int[] */
public array $selectedUserIds = [];
public array $usersSearchable = [];
protected AppRoleService $appRoleService;
#[DataCollectionOf(TableHeader::class)]
public DataCollection $appHeaders;
#[DataCollectionOf(TableHeader::class)]
public DataCollection $userHeaders;
protected RoleService $roleService;
public function boot(AppRoleService $appRoleService, RoleService $roleService): void
{
$this->appRoleService = $appRoleService;
$this->roleService = $roleService;
}
public function mount(int $id): void
{
$role = Role::query()->findOrFail($id);
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
if ($role->priority <= $userPriority) {
abort(403, 'Unauthorized access to this role.');
}
$this->attempt(
action: function () use ($role): void {
$this->roleId = $role->id;
$this->roleName = $role->name;
$this->rolePriority = $role->priority;
},
onError: function (): void {
$this->redirectRoute('access-manager.roles-and-apps.index', navigate: true);
},
showSuccess: false,
);
$this->appHeaders = TableHeader::collect([
new TableHeader(key: 'app_name', label: 'App'),
new TableHeader(key: 'duration', label: 'Validity'),
], DataCollection::class);
$this->userHeaders = TableHeader::collect([
new TableHeader(key: 'name', label: 'Name'),
], DataCollection::class);
}
#[Computed]
public function apps(): DataCollection
{
return $this->appRoleService->getAppsForRole($this->roleId);
}
/**
* Get users assigned to this role via spatie/laravel-permission.
*/
#[Computed]
public function getRoleUsers(): DataCollection
{
return $this->appRoleService->getUsersForRole($this->roleId);
}
public function openAddAppModal(): void
{
$this->isEditingApp = false;
$this->editingAssignmentId = null;
$this->editingAppName = '';
$this->assignForm->reset();
$this->resetErrorBag();
$this->searchApps('');
$this->showAddAppModal = true;
}
public function openEditAppModal(int $assignmentId): void
{
$this->isEditingApp = true;
$this->editingAssignmentId = $assignmentId;
$this->resetErrorBag();
$assignment = collect($this->apps())->firstWhere('id', $assignmentId);
if ($assignment) {
$this->assignForm->appIds = [$assignment['appId'] ?? $assignment['id']];
$this->assignForm->validUpto = $assignment['duration'];
$this->editingAppName = $assignment['appName'];
}
$this->showAddAppModal = true;
}
public function saveApp(): void
{
$this->assignForm->validate();
if ($this->isEditingApp) {
$this->updateApp();
} else {
$this->assignApp();
}
}
public function searchApps(string $value = ''): void
{
$this->appsSearchable = $this
->appRoleService
->searchAppsForSelect($value)
->toArray();
}
public function selectAllApps(): void
{
$this->assignForm->appIds = $this->appRoleService->getAllAppIds();
$this->appsSearchable = $this->appRoleService->searchAppsForSelect('')->toArray();
}
public function clearApps(): void
{
$this->assignForm->appIds = [];
}
public function assignApp(): void
{
$this->authorize(RoleAndAppsPermissionEnum::ConnectApps->value);
$this->attempt(
action: function (): void {
$this->assignForm->validate();
$this->appRoleService->assign(new AssignAppsToRoleData(
roleId: $this->roleId,
validUpto: $this->assignForm->isUnlimited ? now()->addYears(15) : $this->assignForm->validUpto,
appIds: $this->assignForm->appIds,
));
$this->assignForm->reset();
$this->showAddAppModal = false;
unset($this->apps);
},
successMessage: 'Apps assigned successfully!',
);
}
public function updateApp(): void
{
$this->authorize(RoleAndAppsPermissionEnum::ConnectApps->value);
$this->attempt(
action: function (): void {
$this->assignForm->validate();
$this->appRoleService->assign(new AssignAppsToRoleData(
roleId: $this->roleId,
validUpto: $this->assignForm->isUnlimited ? now()->addYears(15) : $this->assignForm->validUpto,
appIds: $this->assignForm->appIds,
));
$this->assignForm->reset();
$this->showAddAppModal = false;
unset($this->apps);
},
successMessage: 'Apps updated successfully!',
);
}
public function detachApp(int $id): void
{
$this->authorize(RoleAndAppsPermissionEnum::ConnectApps->value);
$this->attempt(
action: function () use ($id): void {
$this->appRoleService->detach($id);
unset($this->apps);
},
successMessage: 'App removed from role.',
);
}
public function openAddUserModal(): void
{
$this->reset('selectedUserIds');
$this->resetErrorBag();
$this->searchUsers('');
$this->showAddUserModal = true;
}
public function searchUsers(string $value = ''): void
{
$this->usersSearchable = $this
->appRoleService
->searchUsersForSelect($value)
->toArray();
}
public function assignUser(): void
{
$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->attempt(
action: function () use ($userId): void {
$this->appRoleService->detachUserFromRole($this->roleId, $userId);
unset($this->getRoleUsers);
},
successMessage: 'User removed from role.',
);
}
public function openEditPriorityModal(): void
{
$this->newPriority = $this->rolePriority;
$this->resetErrorBag();
$this->showEditPriorityModal = true;
}
public function savePriority(): void
{
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
$this->validate([
'newPriority' => 'required|integer|min:' . ($userPriority + 1),
], [
'newPriority.min' => 'The priority must be greater than your own highest role priority (' . $userPriority . ').',
]);
$this->attempt(
action: function (): void {
$this->roleService->updatePriority(new UpdateRolePriorityData(
roleId: $this->roleId,
priority: (int) $this->newPriority,
));
$this->rolePriority = (int) $this->newPriority;
$this->showEditPriorityModal = false;
},
successMessage: 'Role priority updated successfully!',
);
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<div class="flex items-center gap-3">
<x-mary-button
:link="route('access-manager.roles-and-apps.index')"
wire:navigate
icon="lucide.arrow-left"
class="btn-sm btn-ghost"
tooltip-right="Back To Roles & Apps"
/>
<h1 class="text-2xl font-semibold text-gray-900">{{ $roleName }}</h1>
<div class="flex items-center gap-1.5">
<x-mary-badge class="badge-soft badge-secondary" value="Priority: {{ $rolePriority }}"/>
<x-mary-button
icon="lucide.pencil"
wire:click="openEditPriorityModal"
spinner="openEditPriorityModal"
class="btn-sm btn-ghost btn-circle"
tooltip="Edit priority"
/>
</div>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row gap-6 w-full">
<x-shared.card title="Connected Apps" icon="lucide-blocks" class="pb-2 w-full h-min">
<x-slot:actions>
@can(RoleAndAppsPermissionEnum::ConnectApps->value)
<x-mary-button wire:click="openAddAppModal" spinner="openAddAppModal" icon="lucide.plus" class="btn-sm">
Add App
</x-mary-button>
@endcan
</x-slot:actions>
@if ($this->apps->count())
<x-mary-table
:headers="$appHeaders->toArray()"
:rows="$this->apps->toArray()"
>
@scope('cell_app_name', $row)
<span class="font-medium text-gray-900">{{ $row['appName'] }}</span>
@endscope
@scope('cell_duration', $row)
{{ Carbon::parse($row['duration'])->diffForHumans() }}
@endscope
@scope('actions', $row)
<div class="flex gap-1">
@can(RoleAndAppsPermissionEnum::ConnectApps->value)
<x-mary-button
icon="lucide.square-pen"
wire:click="openEditAppModal({{ $row['id'] }})"
spinner="openEditAppModal({{ $row['id'] }})"
class="btn-sm btn-soft btn-circle"
/>
<x-mary-button
icon="lucide.trash"
wire:click="requireConfirmation('detachApp', {{ $row['id'] }})"
spinner="requireConfirmation('detachApp', {{ $row['id'] }})"
class="btn-sm btn-error btn-soft btn-circle"
/>
@endcan
</div>
@endscope
</x-mary-table>
@else
<p class="p-6 text-center text-sm text-gray-400">
No apps assigned to this role yet.
</p>
@endif
</x-shared.card>
<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" spinner="openAddUserModal" icon="lucide.plus"
class="btn-sm">
Add User
</x-mary-button>
</x-slot:actions>
@if ($this->getRoleUsers->count())
<x-mary-table
:headers="$userHeaders->toArray()"
:rows="$this->getRoleUsers->toArray()"
>
@scope('cell_name', $row)
<span class="font-medium text-gray-900">{{ $row['name'] }}</span>
@endscope
@scope('actions', $row)
<x-mary-button
icon="lucide.trash"
wire:click="requireConfirmation('detachUser', {{ $row['id'] }})"
spinner="requireConfirmation('detachUser', {{ $row['id'] }})"
variant="danger"
class="btn-sm btn-error btn-soft btn-circle"
/>
@endscope
</x-mary-table>
@else
<p class="p-6 text-center text-sm text-gray-400">
No users assigned to this role yet.
</p>
@endif
</x-shared.card>
</div>
<x-mary-modal wire:model="showAddAppModal"
title="{{ $isEditingApp ? 'Edit App Duration' : 'Add Apps to ' . $roleName }}">
<x-mary-form wire:submit="saveApp">
<div class="space-y-4">
@if(!$isEditingApp)
<div>
<x-shared.choices
wire:model="assignForm.appIds"
:label="__('Connected Apps')"
:options="$appsSearchable"
searchable
search-function="searchApps"
select-all-action="selectAllApps"
clear-action="clearApps"
no-result-text="No apps found."
placeholder="Search apps..."
/>
</div>
@else
<x-mary-input
label="Connected App"
wire:model="editingAppName"
readonly
disabled
class="bg-gray-50 text-gray-500 cursor-not-allowed"
/>
@endif
<div x-data="{ isUnlimited: @entangle('assignForm.isUnlimited') }"
class="flex gap-2 items-baseline-last">
<div class="flex-1 transition-opacity duration-200"
x-bind:class="isUnlimited ? 'opacity-50 pointer-events-none' : ''">
<x-mary-datepicker
label="Date"
wire:model="assignForm.validUpto"
icon="lucide.calendar"
x-bind:disabled="isUnlimited"
x-bind:readonly="isUnlimited"
/>
</div>
<x-mary-toggle
class="toggle-secondary"
label="Unlimited"
x-model="isUnlimited"
/>
</div>
</div>
<x-slot:actions>
<x-mary-button @click="$wire.showAddAppModal = false" class="btn-ghost">
Cancel
</x-mary-button>
<x-mary-button type="submit" class="btn-primary"
icon="{{ $isEditingApp ? 'lucide.save' : 'lucide.link' }}"
spinner="saveApp">
{{ $isEditingApp ? 'Update Duration' : 'Assign Apps' }}
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
<x-mary-modal wire:model="showAddUserModal" title="Add User to {{ $roleName }}">
<x-mary-form wire:submit="assignUser">
<x-mary-choices
wire:model="selectedUserIds"
:options="$usersSearchable"
search-function="searchUsers"
option-label="name"
option-value="id"
no-result-text="No users found."
placeholder="Search users..."
searchable
label="Users"
/>
<x-slot:actions>
<x-mary-button @click="$wire.showAddUserModal = false" class="btn-ghost">
Cancel
</x-mary-button>
<x-mary-button type="submit" icon="lucide.user-plus" spinner="assignUser">
Assign User
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
<x-mary-modal wire:model="showEditPriorityModal" title="Edit Role Priority">
<x-mary-form wire:submit="savePriority">
<x-mary-input
label="Priority"
wire:model="newPriority"
id="role-new-priority-input"
type="number"
placeholder="e.g. 1, 2, 3"
autofocus
/>
<x-slot:actions>
<x-mary-button @click="$wire.showEditPriorityModal = false" class="btn-ghost">
Cancel
</x-mary-button>
<x-mary-button type="submit" class="btn-primary" icon="lucide.save" spinner="savePriority">
Save
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
</div>