diff --git a/app/Data/Permissions/DeactivatedPermissionData.php b/app/Data/Permissions/DeactivatedPermissionData.php new file mode 100644 index 0000000..4c4b20e --- /dev/null +++ b/app/Data/Permissions/DeactivatedPermissionData.php @@ -0,0 +1,15 @@ +id, + name: $user->name, + email: $user->email, + initials: $user->initials(), + roles: RoleData::collect($user->roles, DataCollection::class), + restrictedPermissionIds: $user->restrictedPermissions->pluck('id')->toArray() + ); + } +} diff --git a/app/Livewire/Forms/AssignRolesForm.php b/app/Livewire/Forms/AssignRolesForm.php new file mode 100644 index 0000000..b719907 --- /dev/null +++ b/app/Livewire/Forms/AssignRolesForm.php @@ -0,0 +1,27 @@ + + */ + #[Validate('required|array')] + public array $roleIds = []; + + /** + * Holds the direct permissions toggled for the user (from all selected roles). + * + * @var array + */ + #[Validate('nullable|array')] + public array $permissions = []; +} diff --git a/app/Models/RestrictedUserPermission.php b/app/Models/RestrictedUserPermission.php new file mode 100644 index 0000000..a38beec --- /dev/null +++ b/app/Models/RestrictedUserPermission.php @@ -0,0 +1,39 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + + /** + * @return BelongsTo + */ + public function permission(): BelongsTo + { + return $this->belongsTo(Permission::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 80f20e8..2d1c96f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ diff --git a/app/Services/RoleService.php b/app/Services/RoleService.php index cdd2c24..c5e3db9 100644 --- a/app/Services/RoleService.php +++ b/app/Services/RoleService.php @@ -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 $roleIds + * + * @return DataCollection|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); + } } diff --git a/app/Services/UserService.php b/app/Services/UserService.php new file mode 100644 index 0000000..81d8944 --- /dev/null +++ b/app/Services/UserService.php @@ -0,0 +1,117 @@ + + */ + #[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 + */ + 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 $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(); + }); + } +} diff --git a/database/migrations/2026_05_20_093004_create_restricted_user_permissions_table.php b/database/migrations/2026_05_20_093004_create_restricted_user_permissions_table.php new file mode 100644 index 0000000..0eba0ac --- /dev/null +++ b/database/migrations/2026_05_20_093004_create_restricted_user_permissions_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index 0def7e6..2a4ac10 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -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) ), diff --git a/resources/views/components/shared/choices.blade.php b/resources/views/components/shared/choices.blade.php index f3bcaea..345d0f7 100644 --- a/resources/views/components/shared/choices.blade.php +++ b/resources/views/components/shared/choices.blade.php @@ -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 @@ whereStartsWith('wire:model') }} :options="$options" search-function="{{ $searchFunction }}" option-label="name" diff --git a/resources/views/pages/access-manager/users/⚡index.blade.php b/resources/views/pages/access-manager/users/⚡index.blade.php new file mode 100644 index 0000000..02eee3a --- /dev/null +++ b/resources/views/pages/access-manager/users/⚡index.blade.php @@ -0,0 +1,319 @@ +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 + */ + 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!' + ); + } +}; +?> + +
+ + + @scope('cell_name', $row) +
+ +
+
{{ $row->name }}
+
{{ $row->email }}
+
+
+ @endscope + + @scope('cell_roles', $row) +
+ @foreach($row->roles as $role) + + @endforeach +
+ @endscope + + @scope('cell_permissions', $row) +
+ @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) + + @else + + @endif + @endif + @endforeach + @endif + @endforeach +
+ @endscope + + @scope('cell_apps', $row) +
+ @foreach($row->roles as $role) + @if($role->apps) + @foreach ($role->apps as $app) + + @endforeach + @endif + @endforeach +
+ @endscope + + @scope('actions', $row) +
+ + +
+ @endscope +
+
+ + + + + + @if ($roleWithPermission) +
+ + + + + @foreach ($roleWithPermission?->items() as $role) +
+
+ + {{ $role->name }} + + {{$role->permissions->count()}} {{ $role->permissions->count() >= 2 ? 'Permissions' : 'Permission' }} +
+ +
+ @foreach ($role->permissions as $permission) + + @endforeach +
+
+ @endforeach +
+ @endif + + + + Cancel + + + Save Roles + + +
+
+
diff --git a/routes/web.php b/routes/web.php index 9de2dfe..c8f1652 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 { diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..122f2a6 --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,16 @@ +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');