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.
This commit is contained in:
= 2026-05-21 10:24:57 +00:00
parent fd57ac57e1
commit f02caef80c
18 changed files with 401 additions and 24 deletions

View File

@ -4,6 +4,7 @@
namespace App\Data\Ui\Sidebar; namespace App\Data\Ui\Sidebar;
use BackedEnum;
use Spatie\LaravelData\Data; use Spatie\LaravelData\Data;
final class NavSubItemData extends Data final class NavSubItemData extends Data
@ -12,5 +13,6 @@ public function __construct(
public string $label, public string $label,
public string $route, public string $route,
public string $active_pattern, public string $active_pattern,
public ?BackedEnum $permission = null
) {} ) {}
} }

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Concerns\EnumValuesAsArray;
enum DefaultUserRole: string
{
use EnumValuesAsArray;
case Admin = 'admin';
case User = 'user';
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum AccessManager: string
{
case RoleAndApps = 'role-and-apps';
case Users = 'users';
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum Apps: string
{
case AllApps = 'all-apps';
case Providers = 'providers';
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum Settings: string
{
case Profile = 'profile';
case Security = 'security';
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Helpers;
use App\Enums\DefaultUserRole;
use Illuminate\Support\Collection;
class DashboardRoute
{
/**
* This function will resolve the dashboard route based on the user role.
*/
public static function resolve(Collection $roles): string
{
if ($roles->contains(DefaultUserRole::Admin->value)) {
return 'dashboard.admin';
}
if ($roles->contains(DefaultUserRole::User->value)) {
return 'dashboard.user';
}
return 'home';
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Helpers;
use FilesystemIterator;
use Generator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
class EnumExtractor
{
private string $namespace;
private string $directory;
public function __construct(string $namespace, string $directory)
{
$this->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;
}
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Helpers\DashboardRoute;
use Illuminate\Http\Request;
class ResolveDashboardRouteController extends Controller
{
public function __invoke(Request $request)
{
$user = $request->user();
$route = DashboardRoute::resolve($user->getRoleNames());
return to_route($route);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Responses;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
/**
* This class is used overide the fortify LoginResponse class, so that
* we can redirect the user to the intended page after login.
*/
class LoginResponse implements LoginResponseContract
{
public function toResponse($request)
{
$user = $request->user();
activity()
->causedBy($user)
->withProperties([
'roles' => $user->getRoleNames(),
])
->log('User logged in}');
return to_route('dashboard');
}
}

View File

@ -5,10 +5,12 @@
namespace App\Providers; namespace App\Providers;
use App\Actions\Fortify\{CreateNewUser, ResetUserPassword}; use App\Actions\Fortify\{CreateNewUser, ResetUserPassword};
use App\Http\Responses\LoginResponse;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\{ServiceProvider, Str}; use Illuminate\Support\{ServiceProvider, Str};
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
final class FortifyServiceProvider extends ServiceProvider final class FortifyServiceProvider extends ServiceProvider
@ -16,7 +18,13 @@ final class FortifyServiceProvider extends ServiceProvider
/** /**
* Register any application services. * Register any application services.
*/ */
public function register(): void {} public function register(): void
{
$this->app->singleton(
LoginResponseContract::class,
LoginResponse::class
);
}
/** /**
* Bootstrap any application services. * Bootstrap any application services.

View File

@ -4,7 +4,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents; // use Illuminate\Database\Console\Seeds\WithoutModelEvents;
@ -16,15 +15,10 @@ final class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
$this->call(ConnectionStatusSeeder::class); $this->call(ConnectionStatusSeeder::class);
$this->call(ConnectionProtocolSeeder::class); $this->call(ConnectionProtocolSeeder::class);
$this->call(ConnectionProviderSeeder::class); $this->call(ConnectionProviderSeeder::class);
$this->call(PermissionSeeder::class);
$this->call(ExampleUserWithRoleSeeder::class);
} }
} }

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\DefaultUserRole;
use App\Enums\Permissions\Settings;
use App\Models\Role;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
class DefaultRoleWithPermissionSeeder extends Seeder
{
public function run(): void
{
$role = Role::findOrCreate(DefaultUserRole::Admin->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);
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\DefaultUserRole;
use App\Models\{Role, User};
use Illuminate\Database\Seeder;
class ExampleUserWithRoleSeeder extends Seeder
{
public function run(): void
{
/**
* Create a user for each role;
*/
$this->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);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Helpers\EnumExtractor;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
class PermissionSeeder extends Seeder
{
public function run(): void
{
$enumExtractor = new EnumExtractor('App\\Enums\\Permissions', __DIR__.'/../../app/Enums/Permissions');
$allEnums = $enumExtractor->extract();
foreach ($allEnums as $enumCases) {
foreach ($enumCases as $case) {
Permission::create(['name' => $case->value]);
}
}
}
}

View File

@ -2,6 +2,9 @@
use App\Data\Ui\Sidebar\NavItemData; use App\Data\Ui\Sidebar\NavItemData;
use App\Data\Ui\Sidebar\NavSubItemData; 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 Livewire\Component;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
@ -25,7 +28,7 @@ public function toggle(): void
*/ */
public function navItems(): DataCollection public function navItems(): DataCollection
{ {
return NavItemData::collect([ $items = NavItemData::collect([
new NavItemData( new NavItemData(
label: 'Dashboard', label: 'Dashboard',
icon: 'lucide.house', icon: 'lucide.house',
@ -41,12 +44,14 @@ public function navItems(): DataCollection
new NavSubItemData( new NavSubItemData(
label: 'All Apps', label: 'All Apps',
route: 'apps.index', route: 'apps.index',
active_pattern: 'apps.index' active_pattern: 'apps.index',
permission: Apps::AllApps
), ),
new NavSubItemData( new NavSubItemData(
label: 'Providers', label: 'Providers',
route: 'apps.connectionProviders.index', route: 'apps.connectionProviders.index',
active_pattern: 'apps.connectionProviders.*' active_pattern: 'apps.connectionProviders.*',
permission: Apps::Providers
) )
], DataCollection::class) ], DataCollection::class)
), ),
@ -60,11 +65,13 @@ public function navItems(): DataCollection
label: 'Roles and Apps', label: 'Roles and Apps',
route: 'access-manager.roles-and-apps.index', route: 'access-manager.roles-and-apps.index',
active_pattern: 'access-manager.roles-and-apps.*', active_pattern: 'access-manager.roles-and-apps.*',
permission: AccessManager::RoleAndApps
), ),
new NavSubItemData( new NavSubItemData(
label: 'Users', label: 'Users',
route: 'access-manager.users.index', route: 'access-manager.users.index',
active_pattern: 'access-manager.users.*', active_pattern: 'access-manager.users.*',
permission: AccessManager::Users
), ),
], DataCollection::class) ], DataCollection::class)
), ),
@ -78,15 +85,52 @@ public function navItems(): DataCollection
label: 'Profile', label: 'Profile',
route: 'profile.edit', route: 'profile.edit',
active_pattern: 'profile.edit', active_pattern: 'profile.edit',
permission: Settings::Profile
), ),
new NavSubItemData( new NavSubItemData(
label: 'Security', label: 'Security',
route: 'security.edit', route: 'security.edit',
active_pattern: 'security.edit', active_pattern: 'security.edit',
permission: Settings::Security
), ),
], DataCollection::class) ], DataCollection::class)
), ),
], 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<NavItemData> $items
* @returns DataCollection<NavItemData>
*/
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
<div class="mt-1 space-y-1 pl-9 pr-2"> <div class="mt-1 space-y-1 pl-9 pr-2">
@foreach ($item->sub_menu as $subItem) @foreach ($item->sub_menu as $subItem)
@php $isSubActive = request()->routeIs($subItem->active_pattern); @endphp @php $isSubActive = request()->routeIs($subItem->active_pattern); @endphp
<a @can($subItem->permission)
href="{{ route($subItem->route) }}" <a
wire:navigate href="{{ route($subItem->route) }}"
@class([ wire:navigate
'block px-3 py-2 rounded-md text-sm font-medium transition-colors', @class([
'bg-gray-100 text-blue-700' => $isSubActive, 'block px-3 py-2 rounded-md text-sm font-medium transition-colors',
'text-gray-500 hover:bg-gray-50 hover:text-gray-900' => ! $isSubActive, 'bg-gray-100 text-blue-700' => $isSubActive,
]) 'text-gray-500 hover:bg-gray-50 hover:text-gray-900' => ! $isSubActive,
> ])
{{ $subItem->label }} >
</a> {{ $subItem->label }}
</a>
@endcan
@endforeach @endforeach
</div> </div>
</div> </div>

View File

@ -0,0 +1,13 @@
<?php
use Livewire\Component;
new class extends Component
{
//
};
?>
<div>
{{-- People find pleasure in different ways. I find it in keeping my mind clear. - Marcus Aurelius --}}
</div>

View File

@ -2,12 +2,17 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Controllers\ResolveDashboardRouteController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::view('/', 'welcome')->name('home'); Route::view('/', 'welcome')->name('home');
Route::group(['middleware' => ['auth', 'verified']], function (): void { 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('access-manager')->name('access-manager.')->group(function (): void {
Route::prefix('roles-and-apps')->name('roles-and-apps.')->group(function (): void { Route::prefix('roles-and-apps')->name('roles-and-apps.')->group(function (): void {