From f02caef80c1ef4525741c5c511abb38938343c84 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 21 May 2026 10:24:57 +0000 Subject: [PATCH] Feat: add role seeding, dashboard routing - Added `DefaultUserRole` and permissions enums (`AccessManager`, `Apps`, `Settings`) for centralizing role and permission constants. - Implemented `DashboardRoute` helper to resolve routes dynamically based on user roles. - Introduced seeders for default roles (`DefaultRoleWithPermissionSeeder`, `PermissionSeeder`, `ExampleUserWithRoleSeeder`) with role-permission assignments. - Created `EnumExtractor` utility for parsing enums and generating permissions. - Customized dashboard routes with role-based redirection logic. - Updated sidebar navigation to display role-based menu items with permission checks. --- app/Data/Ui/Sidebar/NavSubItemData.php | 2 + app/Enums/DefaultUserRole.php | 14 +++ app/Enums/Permissions/AccessManager.php | 11 +++ app/Enums/Permissions/Apps.php | 11 +++ app/Enums/Permissions/Settings.php | 11 +++ app/Helpers/DashboardRoute.php | 26 ++++++ app/Helpers/EnumExtractor.php | 90 +++++++++++++++++++ .../ResolveDashboardRouteController.php | 19 ++++ app/Http/Responses/LoginResponse.php | 28 ++++++ app/Providers/FortifyServiceProvider.php | 10 ++- database/seeders/DatabaseSeeder.php | 10 +-- .../DefaultRoleWithPermissionSeeder.php | 27 ++++++ .../seeders/ExampleUserWithRoleSeeder.php | 49 ++++++++++ database/seeders/PermissionSeeder.php | 23 +++++ .../components/dashboard-sidebar.blade.php | 74 ++++++++++++--- .../⚡admin.blade.php} | 0 .../views/pages/dashboards/⚡user.blade.php | 13 +++ routes/web.php | 7 +- 18 files changed, 401 insertions(+), 24 deletions(-) create mode 100644 app/Enums/DefaultUserRole.php create mode 100644 app/Enums/Permissions/AccessManager.php create mode 100644 app/Enums/Permissions/Apps.php create mode 100644 app/Enums/Permissions/Settings.php create mode 100644 app/Helpers/DashboardRoute.php create mode 100644 app/Helpers/EnumExtractor.php create mode 100644 app/Http/Controllers/ResolveDashboardRouteController.php create mode 100644 app/Http/Responses/LoginResponse.php create mode 100644 database/seeders/DefaultRoleWithPermissionSeeder.php create mode 100644 database/seeders/ExampleUserWithRoleSeeder.php create mode 100644 database/seeders/PermissionSeeder.php rename resources/views/pages/{⚡dashboard.blade.php => dashboards/⚡admin.blade.php} (100%) create mode 100644 resources/views/pages/dashboards/⚡user.blade.php diff --git a/app/Data/Ui/Sidebar/NavSubItemData.php b/app/Data/Ui/Sidebar/NavSubItemData.php index 94ad672..e83f9ae 100644 --- a/app/Data/Ui/Sidebar/NavSubItemData.php +++ b/app/Data/Ui/Sidebar/NavSubItemData.php @@ -4,6 +4,7 @@ namespace App\Data\Ui\Sidebar; +use BackedEnum; use Spatie\LaravelData\Data; final class NavSubItemData extends Data @@ -12,5 +13,6 @@ public function __construct( public string $label, public string $route, public string $active_pattern, + public ?BackedEnum $permission = null ) {} } diff --git a/app/Enums/DefaultUserRole.php b/app/Enums/DefaultUserRole.php new file mode 100644 index 0000000..9f033af --- /dev/null +++ b/app/Enums/DefaultUserRole.php @@ -0,0 +1,14 @@ +contains(DefaultUserRole::Admin->value)) { + return 'dashboard.admin'; + } + if ($roles->contains(DefaultUserRole::User->value)) { + return 'dashboard.user'; + } + + return 'home'; + } +} diff --git a/app/Helpers/EnumExtractor.php b/app/Helpers/EnumExtractor.php new file mode 100644 index 0000000..a4e18a6 --- /dev/null +++ b/app/Helpers/EnumExtractor.php @@ -0,0 +1,90 @@ +namespace = mb_trim($namespace, '\\').'\\'; + $this->directory = mb_rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + } + + /** + * Executes the extraction process and returns an array of actual Enum objects. + */ + public function extract(): array + { + $enumObjects = []; + + foreach ($this->getPhpFiles() as $file) { + $className = $this->resolveClassName($file->getPathname()); + + $this->loadClassIfNeeded($className, $file->getPathname()); + + if (enum_exists($className)) { + // $className::cases() returns an array of the actual Enum objects + // e.g., [App\Enums\Status::Active, App\Enums\Status::Inactive] + $enumObjects[$className] = $className::cases(); + } + } + + return $enumObjects; + } + + /** + * Yields only PHP files from the target directory. + */ + private function getPhpFiles(): Generator + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->directory, FilesystemIterator::SKIP_DOTS) + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ('php' === $file->getExtension()) { + yield $file; + } + } + } + + /** + * Converts a physical file path to a fully qualified PSR-4 class name. + */ + private function resolveClassName(string $filePath): string + { + // Strip the base directory from the path + $relativePath = str_replace($this->directory, '', $filePath); + + // Normalize slashes and remove the .php extension + $normalizedPath = str_replace([DIRECTORY_SEPARATOR, '/', '.php'], ['\\', '\\', ''], $relativePath); + + return $this->namespace.mb_ltrim($normalizedPath, '\\'); + } + + /** + * Requires the file into memory if it hasn't been autoloaded yet. + */ + private function loadClassIfNeeded(string $className, string $filePath): void + { + if (! class_exists($className, false) + && ! enum_exists($className, false) + && ! interface_exists($className, false) + ) { + require_once $filePath; + } + } +} diff --git a/app/Http/Controllers/ResolveDashboardRouteController.php b/app/Http/Controllers/ResolveDashboardRouteController.php new file mode 100644 index 0000000..dc8fe40 --- /dev/null +++ b/app/Http/Controllers/ResolveDashboardRouteController.php @@ -0,0 +1,19 @@ +user(); + $route = DashboardRoute::resolve($user->getRoleNames()); + + return to_route($route); + } +} diff --git a/app/Http/Responses/LoginResponse.php b/app/Http/Responses/LoginResponse.php new file mode 100644 index 0000000..99d27ce --- /dev/null +++ b/app/Http/Responses/LoginResponse.php @@ -0,0 +1,28 @@ +user(); + + activity() + ->causedBy($user) + ->withProperties([ + 'roles' => $user->getRoleNames(), + ]) + ->log('User logged in}'); + + return to_route('dashboard'); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index bb51beb..f57951d 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -5,10 +5,12 @@ namespace App\Providers; use App\Actions\Fortify\{CreateNewUser, ResetUserPassword}; +use App\Http\Responses\LoginResponse; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\{ServiceProvider, Str}; +use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; use Laravel\Fortify\Fortify; final class FortifyServiceProvider extends ServiceProvider @@ -16,7 +18,13 @@ final class FortifyServiceProvider extends ServiceProvider /** * Register any application services. */ - public function register(): void {} + public function register(): void + { + $this->app->singleton( + LoginResponseContract::class, + LoginResponse::class + ); + } /** * Bootstrap any application services. diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 3717a0a..b944316 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -4,7 +4,6 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Seeder; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; @@ -16,15 +15,10 @@ final class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - $this->call(ConnectionStatusSeeder::class); $this->call(ConnectionProtocolSeeder::class); $this->call(ConnectionProviderSeeder::class); + $this->call(PermissionSeeder::class); + $this->call(ExampleUserWithRoleSeeder::class); } } diff --git a/database/seeders/DefaultRoleWithPermissionSeeder.php b/database/seeders/DefaultRoleWithPermissionSeeder.php new file mode 100644 index 0000000..effd0de --- /dev/null +++ b/database/seeders/DefaultRoleWithPermissionSeeder.php @@ -0,0 +1,27 @@ +value); + $role->givePermissionTo(Permission::all()); + $role = Role::findOrCreate(DefaultUserRole::User->value); + $this->syncUserPermissions($role); + } + + private function syncUserPermissions(Role $role): void + { + $role->givePermissionTo(Settings::Profile->value, Settings::Security->value); + } +} diff --git a/database/seeders/ExampleUserWithRoleSeeder.php b/database/seeders/ExampleUserWithRoleSeeder.php new file mode 100644 index 0000000..7b588ec --- /dev/null +++ b/database/seeders/ExampleUserWithRoleSeeder.php @@ -0,0 +1,49 @@ +syncAdminRole(); + $this->syncUserRole(); + } + + private function syncAdminRole(): void + { + $role = Role::findOrCreate(DefaultUserRole::Admin->value); + // Check if user exists by email, otherwise create via factory + $user = User::firstWhere('email', 'admin@example.com') + ?? User::factory()->create([ + 'name' => 'Admin', + 'email' => 'admin@example.com', + 'password' => 'password', + ]); + + $user->assignRole($role); + } + + private function syncUserRole(): void + { + $role = Role::findOrCreate(DefaultUserRole::User->value); + + $user = User::firstWhere('email', 'test@example.com') + ?? User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + ]); + $user->assignRole($role); + + } +} diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php new file mode 100644 index 0000000..1eb8ec0 --- /dev/null +++ b/database/seeders/PermissionSeeder.php @@ -0,0 +1,23 @@ +extract(); + foreach ($allEnums as $enumCases) { + foreach ($enumCases as $case) { + Permission::create(['name' => $case->value]); + } + } + } +} diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index 2a4ac10..3cf1f18 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -2,6 +2,9 @@ use App\Data\Ui\Sidebar\NavItemData; use App\Data\Ui\Sidebar\NavSubItemData; +use App\Enums\Permissions\AccessManager; +use App\Enums\Permissions\Apps; +use App\Enums\Permissions\Settings; use Livewire\Component; use Spatie\LaravelData\DataCollection; @@ -25,7 +28,7 @@ public function toggle(): void */ public function navItems(): DataCollection { - return NavItemData::collect([ + $items = NavItemData::collect([ new NavItemData( label: 'Dashboard', icon: 'lucide.house', @@ -41,12 +44,14 @@ public function navItems(): DataCollection new NavSubItemData( label: 'All Apps', route: 'apps.index', - active_pattern: 'apps.index' + active_pattern: 'apps.index', + permission: Apps::AllApps ), new NavSubItemData( label: 'Providers', route: 'apps.connectionProviders.index', - active_pattern: 'apps.connectionProviders.*' + active_pattern: 'apps.connectionProviders.*', + permission: Apps::Providers ) ], DataCollection::class) ), @@ -60,11 +65,13 @@ public function navItems(): DataCollection label: 'Roles and Apps', route: 'access-manager.roles-and-apps.index', active_pattern: 'access-manager.roles-and-apps.*', + permission: AccessManager::RoleAndApps ), new NavSubItemData( label: 'Users', route: 'access-manager.users.index', active_pattern: 'access-manager.users.*', + permission: AccessManager::Users ), ], DataCollection::class) ), @@ -78,15 +85,52 @@ public function navItems(): DataCollection label: 'Profile', route: 'profile.edit', active_pattern: 'profile.edit', + permission: Settings::Profile ), new NavSubItemData( label: 'Security', route: 'security.edit', active_pattern: 'security.edit', + permission: Settings::Security ), ], DataCollection::class) ), ], DataCollection::class); + + // Filter out items the user shouldn't see + return $this->filterNavMenu($items); + } + + /** + * Filter out menus that the current user does not have permission. + * @param DataCollection $items + * @returns DataCollection + */ + private function filterNavMenu(DataCollection $items): DataCollection + { + $filteredItems = $items->toCollection()->filter(function (NavItemData $item) { + if ($item->sub_menu) { + $allowedSubItems = $item->sub_menu->toCollection()->filter(function (NavSubItemData $subItem) { + $permission = $subItem->permission instanceof UnitEnum + ? $subItem->permission->value + : $subItem->permission; + + return auth()->user()->can($permission); + }); + + // If the user has NO access to any submenus, discard the parent menu item + if ($allowedSubItems->isEmpty()) { + return false; + } + + // Update the DataCollection with authorized items + $item->sub_menu = NavSubItemData::collect($allowedSubItems, DataCollection::class); + } + + return true; + }); + + return NavItemData::collect($filteredItems->values()->all(), DataCollection::class); } }; ?> @@ -193,17 +237,19 @@ class="absolute right-full top-1/2 -translate-y-1/2 border-[5px] border-transpar
@foreach ($item->sub_menu as $subItem) @php $isSubActive = request()->routeIs($subItem->active_pattern); @endphp - $isSubActive, - 'text-gray-500 hover:bg-gray-50 hover:text-gray-900' => ! $isSubActive, - ]) - > - {{ $subItem->label }} - + @can($subItem->permission) + $isSubActive, + 'text-gray-500 hover:bg-gray-50 hover:text-gray-900' => ! $isSubActive, + ]) + > + {{ $subItem->label }} + + @endcan @endforeach
diff --git a/resources/views/pages/⚡dashboard.blade.php b/resources/views/pages/dashboards/⚡admin.blade.php similarity index 100% rename from resources/views/pages/⚡dashboard.blade.php rename to resources/views/pages/dashboards/⚡admin.blade.php diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php new file mode 100644 index 0000000..f5c4ef9 --- /dev/null +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -0,0 +1,13 @@ + + +
+ {{-- People find pleasure in different ways. I find it in keeping my mind clear. - Marcus Aurelius --}} +
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index c8f1652..f76baeb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,12 +2,17 @@ declare(strict_types=1); +use App\Http\Controllers\ResolveDashboardRouteController; use Illuminate\Support\Facades\Route; Route::view('/', 'welcome')->name('home'); Route::group(['middleware' => ['auth', 'verified']], function (): void { - Route::livewire('dashboard', 'pages::dashboard')->name('dashboard'); + Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard'); + Route::prefix('dashboard')->name('dashboard.')->group(function (): void { + Route::livewire('/user', 'pages::dashboards.user')->name('user'); + Route::livewire('admin', 'pages::dashboards.admin')->name('admin'); + }); Route::prefix('access-manager')->name('access-manager.')->group(function (): void { Route::prefix('roles-and-apps')->name('roles-and-apps.')->group(function (): void {