From a461f0bb426d743c58b9ccc850f87c96fb4bb80c Mon Sep 17 00:00:00 2001 From: Saufi Date: Mon, 11 May 2026 12:17:32 +0800 Subject: [PATCH 1/5] user list --- .claude/settings.local.json | 8 ++++ app/Http/Controllers/UserController.php | 15 +++++++ resources/views/layouts/navigation.blade.php | 6 +++ resources/views/users/index.blade.php | 44 ++++++++++++++++++++ routes/web.php | 2 + tests/Feature/UserIndexTest.php | 25 +++++++++++ 6 files changed, 100 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 app/Http/Controllers/UserController.php create mode 100644 resources/views/users/index.blade.php create mode 100644 tests/Feature/UserIndexTest.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..37e7337 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(php artisan *)", + "Bash(vendor/bin/pint --dirty --format agent)" + ] + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..e0ef0c5 --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,15 @@ +get(); + + return view('users.index', compact('users')); + } +} diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index c64bf64..af4fd24 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -15,6 +15,9 @@ {{ __('Dashboard') }} + + {{ __('Users') }} + @@ -70,6 +73,9 @@ {{ __('Dashboard') }} + + {{ __('Users') }} + diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php new file mode 100644 index 0000000..0676ab5 --- /dev/null +++ b/resources/views/users/index.blade.php @@ -0,0 +1,44 @@ + + +

+ {{ __('Users') }} +

+
+ +
+
+
+
+ + + + + + + + + + + @foreach ($users as $index => $user) + + + + + + + @endforeach + + @if ($users->isEmpty()) + + + + @endif + +
#{{ __('Name') }}{{ __('Email') }}{{ __('Joined') }}
{{ $index + 1 }}{{ $user->name }}{{ $user->email }}{{ $user->created_at->format('d M Y') }}
+ {{ __('No users found.') }} +
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 74bb7ca..ce44b28 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ middleware(['auth', 'verified'])->name('dashboard'); Route::middleware('auth')->group(function () { + Route::get('/users', [UserController::class, 'index'])->name('users.index'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); diff --git a/tests/Feature/UserIndexTest.php b/tests/Feature/UserIndexTest.php new file mode 100644 index 0000000..ab41f9a --- /dev/null +++ b/tests/Feature/UserIndexTest.php @@ -0,0 +1,25 @@ +get('/users')->assertRedirect('/login'); +}); + +test('authenticated users can view the users page', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/users') + ->assertOk() + ->assertViewIs('users.index'); +}); + +test('users page lists all users', function () { + $users = User::factory()->count(3)->create(); + + $this->actingAs($users->first()) + ->get('/users') + ->assertOk() + ->assertViewHas('users', fn ($viewUsers) => $viewUsers->count() === User::count()); +}); From 3e79e74cb7d8ad024dbbf7076b238a88f6383b43 Mon Sep 17 00:00:00 2001 From: Saufi Date: Mon, 11 May 2026 12:20:39 +0800 Subject: [PATCH 2/5] user pagination --- .claude/settings.local.json | 3 +- app/Http/Controllers/UserController.php | 9 ++++- resources/views/users/_table.blade.php | 34 ++++++++++++++++ resources/views/users/index.blade.php | 54 ++++++++++++------------- tests/Feature/UserIndexTest.php | 30 ++++++++++++-- 5 files changed, 95 insertions(+), 35 deletions(-) create mode 100644 resources/views/users/_table.blade.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 37e7337..e5f9503 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(php artisan *)", - "Bash(vendor/bin/pint --dirty --format agent)" + "Bash(vendor/bin/pint --dirty --format agent)", + "mcp__laravel-boost__search-docs" ] } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index e0ef0c5..6675d8a 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -3,12 +3,17 @@ namespace App\Http\Controllers; use App\Models\User; +use Illuminate\Http\Request; class UserController extends Controller { - public function index() + public function index(Request $request) { - $users = User::orderBy('name')->get(); + $users = User::orderBy('name')->paginate(10); + + if ($request->ajax()) { + return view('users._table', compact('users')); + } return view('users.index', compact('users')); } diff --git a/resources/views/users/_table.blade.php b/resources/views/users/_table.blade.php new file mode 100644 index 0000000..93b983a --- /dev/null +++ b/resources/views/users/_table.blade.php @@ -0,0 +1,34 @@ + + + + + + + + + + + @foreach ($users as $user) + + + + + + + @endforeach + + @if ($users->isEmpty()) + + + + @endif + +
#{{ __('Name') }}{{ __('Email') }}{{ __('Joined') }}
{{ $users->firstItem() + $loop->index }}{{ $user->name }}{{ $user->email }}{{ $user->created_at->format('d M Y') }}
+ {{ __('No users found.') }} +
+ +@if ($users->hasPages()) +
+ {{ $users->links() }} +
+@endif diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index 0676ab5..9e63aaa 100644 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -8,35 +8,33 @@
-
- - - - - - - - - - - @foreach ($users as $index => $user) - - - - - - - @endforeach +
+
+ {{ __('Loading...') }} +
- @if ($users->isEmpty()) -
- - - @endif - -
#{{ __('Name') }}{{ __('Email') }}{{ __('Joined') }}
{{ $index + 1 }}{{ $user->name }}{{ $user->email }}{{ $user->created_at->format('d M Y') }}
- {{ __('No users found.') }} -
+
+ @include('users._table') +
diff --git a/tests/Feature/UserIndexTest.php b/tests/Feature/UserIndexTest.php index ab41f9a..a1ef24b 100644 --- a/tests/Feature/UserIndexTest.php +++ b/tests/Feature/UserIndexTest.php @@ -15,11 +15,33 @@ test('authenticated users can view the users page', function () { ->assertViewIs('users.index'); }); -test('users page lists all users', function () { - $users = User::factory()->count(3)->create(); +test('users page passes paginated users to view', function () { + $user = User::factory()->create(); - $this->actingAs($users->first()) + $this->actingAs($user) ->get('/users') ->assertOk() - ->assertViewHas('users', fn ($viewUsers) => $viewUsers->count() === User::count()); + ->assertViewHas('users'); +}); + +test('ajax request returns table partial', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->withHeader('X-Requested-With', 'XMLHttpRequest') + ->get('/users') + ->assertOk() + ->assertViewIs('users._table'); +}); + +test('ajax pagination returns correct page', function () { + $user = User::factory()->create(); + User::factory()->count(15)->create(); + + $this->actingAs($user) + ->withHeader('X-Requested-With', 'XMLHttpRequest') + ->get('/users?page=2') + ->assertOk() + ->assertViewIs('users._table') + ->assertViewHas('users', fn ($users) => $users->currentPage() === 2); }); From a300eced76965d22bad37a087d297752d2306f5e Mon Sep 17 00:00:00 2001 From: Saufi Date: Mon, 11 May 2026 12:26:07 +0800 Subject: [PATCH 3/5] role listing --- .claude/settings.local.json | 9 ++++ app/Http/Controllers/RoleController.php | 20 +++++++++ app/Models/Role.php | 15 +++++++ database/factories/RoleFactory.php | 25 +++++++++++ .../2026_05_11_042341_create_roles_table.php | 29 +++++++++++++ database/seeders/RoleSeeder.php | 22 ++++++++++ resources/views/layouts/navigation.blade.php | 6 +++ resources/views/roles/_table.blade.php | 34 +++++++++++++++ resources/views/roles/index.blade.php | 42 +++++++++++++++++++ routes/web.php | 2 + tests/Feature/RoleIndexTest.php | 41 ++++++++++++++++++ 11 files changed, 245 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 app/Http/Controllers/RoleController.php create mode 100644 app/Models/Role.php create mode 100644 database/factories/RoleFactory.php create mode 100644 database/migrations/2026_05_11_042341_create_roles_table.php create mode 100644 database/seeders/RoleSeeder.php create mode 100644 resources/views/roles/_table.blade.php create mode 100644 resources/views/roles/index.blade.php create mode 100644 tests/Feature/RoleIndexTest.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b94054c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "mcp__laravel-boost__database-schema", + "Bash(php artisan *)", + "Bash(vendor/bin/pint --dirty --format agent)" + ] + } +} diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php new file mode 100644 index 0000000..f2dbc01 --- /dev/null +++ b/app/Http/Controllers/RoleController.php @@ -0,0 +1,20 @@ +paginate(10); + + if ($request->ajax()) { + return view('roles._table', compact('roles')); + } + + return view('roles.index', compact('roles')); + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000..a68d904 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,15 @@ + */ + use HasFactory; + + protected $fillable = ['name', 'description']; +} diff --git a/database/factories/RoleFactory.php b/database/factories/RoleFactory.php new file mode 100644 index 0000000..ffbe866 --- /dev/null +++ b/database/factories/RoleFactory.php @@ -0,0 +1,25 @@ + + */ +class RoleFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->word(), + 'description' => fake()->sentence(), + ]; + } +} diff --git a/database/migrations/2026_05_11_042341_create_roles_table.php b/database/migrations/2026_05_11_042341_create_roles_table.php new file mode 100644 index 0000000..5ea3089 --- /dev/null +++ b/database/migrations/2026_05_11_042341_create_roles_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name')->unique(); + $table->string('description')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('roles'); + } +}; diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php new file mode 100644 index 0000000..1933f21 --- /dev/null +++ b/database/seeders/RoleSeeder.php @@ -0,0 +1,22 @@ + 'Admin', 'description' => 'Full access to all resources'], + ['name' => 'Editor', 'description' => 'Can create and edit content'], + ['name' => 'Viewer', 'description' => 'Read-only access'], + ]; + + foreach ($roles as $role) { + Role::firstOrCreate(['name' => $role['name']], $role); + } + } +} diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index c64bf64..a2b2431 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -15,6 +15,9 @@ {{ __('Dashboard') }} + + {{ __('Roles') }} +
@@ -70,6 +73,9 @@ {{ __('Dashboard') }} + + {{ __('Roles') }} + diff --git a/resources/views/roles/_table.blade.php b/resources/views/roles/_table.blade.php new file mode 100644 index 0000000..b6b38b2 --- /dev/null +++ b/resources/views/roles/_table.blade.php @@ -0,0 +1,34 @@ + + + + + + + + + + + @foreach ($roles as $role) + + + + + + + @endforeach + + @if ($roles->isEmpty()) + + + + @endif + +
#{{ __('Name') }}{{ __('Description') }}{{ __('Created') }}
{{ $roles->firstItem() + $loop->index }}{{ $role->name }}{{ $role->description ?? '—' }}{{ $role->created_at->format('d M Y') }}
+ {{ __('No roles found.') }} +
+ +@if ($roles->hasPages()) +
+ {{ $roles->links() }} +
+@endif diff --git a/resources/views/roles/index.blade.php b/resources/views/roles/index.blade.php new file mode 100644 index 0000000..114f6de --- /dev/null +++ b/resources/views/roles/index.blade.php @@ -0,0 +1,42 @@ + + +

+ {{ __('Roles') }} +

+
+ +
+
+
+
+
+ {{ __('Loading...') }} +
+ +
+ @include('roles._table') +
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 74bb7ca..9c022e8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ middleware(['auth', 'verified'])->name('dashboard'); Route::middleware('auth')->group(function () { + Route::get('/roles', [RoleController::class, 'index'])->name('roles.index'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); diff --git a/tests/Feature/RoleIndexTest.php b/tests/Feature/RoleIndexTest.php new file mode 100644 index 0000000..78a2f2f --- /dev/null +++ b/tests/Feature/RoleIndexTest.php @@ -0,0 +1,41 @@ +get('/roles')->assertRedirect('/login'); +}); + +test('authenticated users can view the roles page', function () { + $this->actingAs(User::factory()->create()) + ->get('/roles') + ->assertOk() + ->assertViewIs('roles.index'); +}); + +test('roles page passes paginated roles to view', function () { + $this->actingAs(User::factory()->create()) + ->get('/roles') + ->assertOk() + ->assertViewHas('roles'); +}); + +test('ajax request returns roles table partial', function () { + $this->actingAs(User::factory()->create()) + ->withHeader('X-Requested-With', 'XMLHttpRequest') + ->get('/roles') + ->assertOk() + ->assertViewIs('roles._table'); +}); + +test('ajax pagination returns correct page', function () { + Role::factory()->count(15)->create(); + + $this->actingAs(User::factory()->create()) + ->withHeader('X-Requested-With', 'XMLHttpRequest') + ->get('/roles?page=2') + ->assertOk() + ->assertViewIs('roles._table') + ->assertViewHas('roles', fn ($roles) => $roles->currentPage() === 2); +}); From eaf0ba7a4d8283e440b0bcac421e3983515fc1d3 Mon Sep 17 00:00:00 2001 From: Saufi Date: Mon, 11 May 2026 12:29:11 +0800 Subject: [PATCH 4/5] add new role functionality --- .claude/settings.local.json | 3 +- app/Http/Controllers/RoleController.php | 16 +++++++ app/Http/Requests/StoreRoleRequest.php | 28 +++++++++++++ resources/views/roles/create.blade.php | 56 +++++++++++++++++++++++++ resources/views/roles/index.blade.php | 23 ++++++++-- routes/web.php | 2 + tests/Feature/StoreRoleTest.php | 52 +++++++++++++++++++++++ 7 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 app/Http/Requests/StoreRoleRequest.php create mode 100644 resources/views/roles/create.blade.php create mode 100644 tests/Feature/StoreRoleTest.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b94054c..59f1246 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "mcp__laravel-boost__database-schema", "Bash(php artisan *)", - "Bash(vendor/bin/pint --dirty --format agent)" + "Bash(vendor/bin/pint --dirty --format agent)", + "mcp__laravel-boost__search-docs" ] } } diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php index f2dbc01..cb60165 100644 --- a/app/Http/Controllers/RoleController.php +++ b/app/Http/Controllers/RoleController.php @@ -2,8 +2,11 @@ namespace App\Http\Controllers; +use App\Http\Requests\StoreRoleRequest; use App\Models\Role; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\View\View; class RoleController extends Controller { @@ -17,4 +20,17 @@ class RoleController extends Controller return view('roles.index', compact('roles')); } + + public function create(): View + { + return view('roles.create'); + } + + public function store(StoreRoleRequest $request): RedirectResponse + { + Role::create($request->validated()); + + return redirect()->route('roles.index') + ->with('status', 'role-created'); + } } diff --git a/app/Http/Requests/StoreRoleRequest.php b/app/Http/Requests/StoreRoleRequest.php new file mode 100644 index 0000000..ef2c983 --- /dev/null +++ b/app/Http/Requests/StoreRoleRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255', 'unique:roles,name'], + 'description' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/resources/views/roles/create.blade.php b/resources/views/roles/create.blade.php new file mode 100644 index 0000000..3a24f01 --- /dev/null +++ b/resources/views/roles/create.blade.php @@ -0,0 +1,56 @@ + + +

+ {{ __('New Role') }} +

+
+ +
+
+
+
+
+
+

+ {{ __('Role Details') }} +

+ +

+ {{ __('Create a new role to assign to users in the system.') }} +

+
+ +
+ @csrf + +
+ + + +
+ +
+ + + +
+ +
+ {{ __('Create Role') }} + + + {{ __('Cancel') }} + +
+
+
+
+
+
+
+
diff --git a/resources/views/roles/index.blade.php b/resources/views/roles/index.blade.php index 114f6de..dae2c3c 100644 --- a/resources/views/roles/index.blade.php +++ b/resources/views/roles/index.blade.php @@ -1,12 +1,29 @@ -

- {{ __('Roles') }} -

+
+

+ {{ __('Roles') }} +

+ + {{ __('New Role') }} + +
+ @if (session('status') === 'role-created') +
+ {{ __('Role created successfully.') }} +
+ @endif +
group(function () { Route::get('/roles', [RoleController::class, 'index'])->name('roles.index'); + Route::get('/roles/create', [RoleController::class, 'create'])->name('roles.create'); + Route::post('/roles', [RoleController::class, 'store'])->name('roles.store'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); diff --git a/tests/Feature/StoreRoleTest.php b/tests/Feature/StoreRoleTest.php new file mode 100644 index 0000000..c6b0994 --- /dev/null +++ b/tests/Feature/StoreRoleTest.php @@ -0,0 +1,52 @@ +get('/roles/create')->assertRedirect('/login'); +}); + +test('authenticated users can access the create role page', function () { + $this->actingAs(User::factory()->create()) + ->get('/roles/create') + ->assertOk() + ->assertViewIs('roles.create'); +}); + +test('authenticated users can create a role', function () { + $this->actingAs(User::factory()->create()) + ->post('/roles', ['name' => 'Manager', 'description' => 'Manages things']) + ->assertRedirect(route('roles.index')) + ->assertSessionHas('status', 'role-created'); + + $this->assertDatabaseHas('roles', ['name' => 'Manager', 'description' => 'Manages things']); +}); + +test('role name is required', function () { + $this->actingAs(User::factory()->create()) + ->post('/roles', ['name' => '', 'description' => 'Some description']) + ->assertSessionHasErrors('name'); +}); + +test('role name must be unique', function () { + Role::factory()->create(['name' => 'Admin']); + + $this->actingAs(User::factory()->create()) + ->post('/roles', ['name' => 'Admin']) + ->assertSessionHasErrors('name'); +}); + +test('description is optional', function () { + $this->actingAs(User::factory()->create()) + ->post('/roles', ['name' => 'Viewer']) + ->assertRedirect(route('roles.index')); + + $this->assertDatabaseHas('roles', ['name' => 'Viewer', 'description' => null]); +}); + +test('guests cannot create a role', function () { + $this->post('/roles', ['name' => 'Admin'])->assertRedirect('/login'); + + $this->assertDatabaseMissing('roles', ['name' => 'Admin']); +}); From e404aee576408d7020a3550599540229a2126b95 Mon Sep 17 00:00:00 2001 From: Saufi Date: Mon, 11 May 2026 14:13:25 +0800 Subject: [PATCH 5/5] tambah relation user-role --- .claude/settings.local.json | 3 - app/Http/Controllers/UserController.php | 19 +++++ app/Http/Requests/UpdateUserRolesRequest.php | 28 ++++++ app/Models/Role.php | 6 ++ app/Models/User.php | 6 ++ ...26_05_11_044620_create_role_user_table.php | 28 ++++++ resources/views/layouts/navigation.blade.php | 10 ++- resources/views/users/_table.blade.php | 8 +- resources/views/users/edit.blade.php | 65 ++++++++++++++ resources/views/users/index.blade.php | 5 +- routes/web.php | 4 +- tests/Feature/EditUserTest.php | 85 +++++++++++++++++++ 12 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 app/Http/Requests/UpdateUserRolesRequest.php create mode 100644 database/migrations/2026_05_11_044620_create_role_user_table.php create mode 100644 resources/views/users/edit.blade.php create mode 100644 tests/Feature/EditUserTest.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4dc6383..59f1246 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,10 +1,7 @@ { "permissions": { "allow": [ -<<<<<<< HEAD -======= "mcp__laravel-boost__database-schema", ->>>>>>> role-module "Bash(php artisan *)", "Bash(vendor/bin/pint --dirty --format agent)", "mcp__laravel-boost__search-docs" diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 6675d8a..2163c02 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -2,8 +2,12 @@ namespace App\Http\Controllers; +use App\Http\Requests\UpdateUserRolesRequest; +use App\Models\Role; use App\Models\User; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\View\View; class UserController extends Controller { @@ -17,4 +21,19 @@ class UserController extends Controller return view('users.index', compact('users')); } + + public function edit(User $user): View + { + $roles = Role::orderBy('name')->get(); + + return view('users.edit', compact('user', 'roles')); + } + + public function update(UpdateUserRolesRequest $request, User $user): RedirectResponse + { + $user->roles()->sync($request->validated()['roles'] ?? []); + + return redirect()->route('users.index') + ->with('status', 'user-updated'); + } } diff --git a/app/Http/Requests/UpdateUserRolesRequest.php b/app/Http/Requests/UpdateUserRolesRequest.php new file mode 100644 index 0000000..03b8b09 --- /dev/null +++ b/app/Http/Requests/UpdateUserRolesRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'roles' => ['nullable', 'array'], + 'roles.*' => ['integer', 'exists:roles,id'], + ]; + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php index a68d904..c8829b4 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -5,6 +5,7 @@ namespace App\Models; use Database\Factories\RoleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Role extends Model { @@ -12,4 +13,9 @@ class Role extends Model use HasFactory; protected $fillable = ['name', 'description']; + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..ee6a6d4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -29,4 +30,9 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } } diff --git a/database/migrations/2026_05_11_044620_create_role_user_table.php b/database/migrations/2026_05_11_044620_create_role_user_table.php new file mode 100644 index 0000000..bc623bd --- /dev/null +++ b/database/migrations/2026_05_11_044620_create_role_user_table.php @@ -0,0 +1,28 @@ +foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('role_id')->constrained()->cascadeOnDelete(); + $table->primary(['user_id', 'role_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('role_user'); + } +}; diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 63fc66a..9b508b8 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -15,9 +15,10 @@ {{ __('Dashboard') }} - + {{ __('Users') }} - + + {{ __('Roles') }}
@@ -75,9 +76,10 @@ {{ __('Dashboard') }} - + {{ __('Users') }} - + + {{ __('Roles') }}
diff --git a/resources/views/users/_table.blade.php b/resources/views/users/_table.blade.php index 93b983a..67667f6 100644 --- a/resources/views/users/_table.blade.php +++ b/resources/views/users/_table.blade.php @@ -5,6 +5,7 @@ {{ __('Name') }} {{ __('Email') }} {{ __('Joined') }} + @@ -14,12 +15,17 @@ {{ $user->name }} {{ $user->email }} {{ $user->created_at->format('d M Y') }} + + + {{ __('Edit') }} + + @endforeach @if ($users->isEmpty()) - + {{ __('No users found.') }} diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php new file mode 100644 index 0000000..5341c6c --- /dev/null +++ b/resources/views/users/edit.blade.php @@ -0,0 +1,65 @@ + + +

+ {{ __('Edit User') }}: {{ $user->name }} +

+
+ +
+
+
+
+
+
+

+ {{ __('Assign Roles') }} +

+ +

+ {{ __('Select the roles to assign to this user.') }} +

+
+ +
+ @csrf + @method('put') + +
+ @forelse ($roles as $role) + + @empty +

{{ __('No roles available. Create one first.') }}

+ @endforelse +
+ + + + +
+ {{ __('Save Roles') }} + + + {{ __('Cancel') }} + +
+ +
+
+
+
+
+
diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index 9e63aaa..90168b0 100644 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -21,9 +21,10 @@ this.loading = false; } }" - @click.prevent=" + @click=" const link = $event.target.closest('a[href]'); - if (link && $refs.tableContainer.contains(link)) { + if (link && link.closest('nav[role=navigation]') && $refs.tableContainer.contains(link)) { + $event.preventDefault(); paginate(link.href); } " diff --git a/routes/web.php b/routes/web.php index fb24986..3e6a2b5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,8 @@ group(function () { Route::get('/users', [UserController::class, 'index'])->name('users.index'); + Route::get('/users/{user}/edit', [UserController::class, 'edit'])->name('users.edit'); + Route::put('/users/{user}', [UserController::class, 'update'])->name('users.update'); Route::get('/roles', [RoleController::class, 'index'])->name('roles.index'); Route::get('/roles/create', [RoleController::class, 'create'])->name('roles.create'); Route::post('/roles', [RoleController::class, 'store'])->name('roles.store'); diff --git a/tests/Feature/EditUserTest.php b/tests/Feature/EditUserTest.php new file mode 100644 index 0000000..eeb9c82 --- /dev/null +++ b/tests/Feature/EditUserTest.php @@ -0,0 +1,85 @@ +create(); + + $this->get(route('users.edit', $user))->assertRedirect('/login'); +}); + +test('authenticated users can access the edit user page', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('users.edit', $user)) + ->assertOk() + ->assertViewIs('users.edit') + ->assertViewHas('user', $user) + ->assertViewHas('roles'); +}); + +test('edit page shows all available roles', function () { + $roles = Role::factory()->count(3)->create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('users.edit', $user)) + ->assertOk() + ->assertViewHas('roles', fn ($viewRoles) => $viewRoles->count() === $roles->count()); +}); + +test('can assign roles to a user', function () { + $user = User::factory()->create(); + $roles = Role::factory()->count(2)->create(); + + $this->actingAs($user) + ->put(route('users.update', $user), ['roles' => $roles->pluck('id')->all()]) + ->assertRedirect(route('users.index')) + ->assertSessionHas('status', 'user-updated'); + + expect($user->roles()->count())->toBe(2); +}); + +test('can remove all roles from a user', function () { + $user = User::factory()->create(); + $role = Role::factory()->create(); + $user->roles()->attach($role); + + $this->actingAs($user) + ->put(route('users.update', $user), []) + ->assertRedirect(route('users.index')); + + expect($user->roles()->count())->toBe(0); +}); + +test('syncs roles replacing previous assignments', function () { + $user = User::factory()->create(); + $oldRole = Role::factory()->create(); + $newRole = Role::factory()->create(); + $user->roles()->attach($oldRole); + + $this->actingAs($user) + ->put(route('users.update', $user), ['roles' => [$newRole->id]]); + + expect($user->roles()->pluck('id')->all())->toBe([$newRole->id]); +}); + +test('role ids must exist in the database', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->put(route('users.update', $user), ['roles' => [999]]) + ->assertSessionHasErrors('roles.*'); +}); + +test('guests cannot update user roles', function () { + $user = User::factory()->create(); + $role = Role::factory()->create(); + + $this->put(route('users.update', $user), ['roles' => [$role->id]]) + ->assertRedirect('/login'); + + expect($user->roles()->count())->toBe(0); +});