From aeee3d2640a5e3b23dcdd87c7d10c807ea63cda1 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 25 Mar 2026 00:38:30 +0100 Subject: [PATCH] Implement admin and user management foundation --- .../Admin/UserManagementController.php | 37 ++++ .../Admin/UpdateUserAdminStatusRequest.php | 29 +++ app/Models/User.php | 1 + app/Providers/AppServiceProvider.php | 11 ++ database/factories/UserFactory.php | 11 ++ ..._24_233202_add_is_admin_to_users_table.php | 28 +++ database/seeders/DatabaseSeeder.php | 8 +- database/seeders/UserSeeder.php | 27 +++ resources/js/components/AppSidebar.vue | 34 +++- resources/js/pages/admin/users/Index.vue | 166 ++++++++++++++++++ resources/js/types/auth.ts | 13 +- resources/js/types/index.ts | 1 + resources/js/types/models/user-model.ts | 11 ++ resources/js/types/page-props.ts | 6 + routes/web.php | 9 + .../Admin/UserManagementControllerTest.php | 87 +++++++++ tests/Feature/Models/UserTest.php | 51 ++++++ 17 files changed, 505 insertions(+), 25 deletions(-) create mode 100644 app/Http/Controllers/Admin/UserManagementController.php create mode 100644 app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php create mode 100644 database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php create mode 100644 database/seeders/UserSeeder.php create mode 100644 resources/js/pages/admin/users/Index.vue create mode 100644 resources/js/types/models/user-model.ts create mode 100644 resources/js/types/page-props.ts create mode 100644 tests/Feature/Admin/UserManagementControllerTest.php create mode 100644 tests/Feature/Models/UserTest.php diff --git a/app/Http/Controllers/Admin/UserManagementController.php b/app/Http/Controllers/Admin/UserManagementController.php new file mode 100644 index 0000000..349ea43 --- /dev/null +++ b/app/Http/Controllers/Admin/UserManagementController.php @@ -0,0 +1,37 @@ + session('status'), + 'users' => User::query() + ->orderByDesc('is_admin') + ->orderBy('name') + ->get(), + ]); + } + + public function update(UpdateUserAdminStatusRequest $request, User $user): RedirectResponse + { + $user->forceFill([ + 'is_admin' => $request->boolean('is_admin'), + ])->save(); + + $status = $user->is_admin + ? "{$user->name} now has admin access." + : "{$user->name} no longer has admin access."; + + return to_route('admin.users.index')->with('status', $status); + } +} diff --git a/app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php b/app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php new file mode 100644 index 0000000..9075346 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateUserAdminStatusRequest.php @@ -0,0 +1,29 @@ +user()?->is_admin === true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'is_admin' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f40a71d..6398fa1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -27,6 +27,7 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', + 'is_admin' => 'boolean', 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..bf402d4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,11 @@ namespace App\Providers; +use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -23,9 +25,18 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + $this->configureAuthorization(); $this->configureDefaults(); } + /** + * Configure authorization behaviors. + */ + protected function configureAuthorization(): void + { + Gate::define('access-admin', fn (User $user): bool => $user->is_admin); + } + /** * Configure default behaviors for production-ready applications. */ diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 98825c8..3148b81 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -28,6 +28,7 @@ class UserFactory extends Factory 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), + 'is_admin' => false, 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, @@ -57,4 +58,14 @@ class UserFactory extends Factory 'two_factor_confirmed_at' => now(), ]); } + + /** + * Indicate that the user has application admin access. + */ + public function admin(): static + { + return $this->state(fn (array $attributes) => [ + 'is_admin' => true, + ]); + } } diff --git a/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php b/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php new file mode 100644 index 0000000..87295b3 --- /dev/null +++ b/database/migrations/2026_03_24_233202_add_is_admin_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_admin')->default(false)->after('email_verified_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..c0dcc6a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use App\Models\User; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -13,11 +12,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + UserSeeder::class, ]); } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 0000000..422ab25 --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,27 @@ +admin()->create([ + 'name' => 'Admin User', + 'email' => 'admin@kanbangit.test', + ]); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + User::factory(8)->create(); + } +} diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index e1873b3..e2199ab 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,7 @@ + + diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 26057e1..f1294b6 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -1,13 +1,6 @@ -export type User = { - id: number; - name: string; - email: string; - avatar?: string; - email_verified_at: string | null; - created_at: string; - updated_at: string; - [key: string]: unknown; -}; +import type { UserModel } from '@/types/models/user-model'; + +export type User = UserModel; export type Auth = { user: User; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index afa7f48..0d3c79c 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -1,3 +1,4 @@ export * from './auth'; export * from './navigation'; +export * from './page-props'; export * from './ui'; diff --git a/resources/js/types/models/user-model.ts b/resources/js/types/models/user-model.ts new file mode 100644 index 0000000..9590d1b --- /dev/null +++ b/resources/js/types/models/user-model.ts @@ -0,0 +1,11 @@ +export type UserModel = { + id: number; + name: string; + email: string; + avatar?: string; + email_verified_at: string | null; + is_admin: boolean; + created_at: string; + updated_at: string; + [key: string]: unknown; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts new file mode 100644 index 0000000..f9504dd --- /dev/null +++ b/resources/js/types/page-props.ts @@ -0,0 +1,6 @@ +import type { UserModel } from '@/types/models/user-model'; + +export type AdminUsersPageProps = { + users: UserModel[]; + status?: string | null; +}; diff --git a/routes/web.php b/routes/web.php index bb696d1..2064ef9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { Route::inertia('dashboard', 'Dashboard')->name('dashboard'); }); +Route::middleware(['auth', 'verified', 'can:access-admin']) + ->prefix('admin') + ->name('admin.') + ->group(function () { + Route::get('users', [UserManagementController::class, 'index'])->name('users.index'); + Route::patch('users/{user}/admin-status', [UserManagementController::class, 'update'])->name('users.update'); + }); + require __DIR__.'/settings.php'; diff --git a/tests/Feature/Admin/UserManagementControllerTest.php b/tests/Feature/Admin/UserManagementControllerTest.php new file mode 100644 index 0000000..b38176c --- /dev/null +++ b/tests/Feature/Admin/UserManagementControllerTest.php @@ -0,0 +1,87 @@ +admin()->create(); + $member = User::factory()->create([ + 'name' => 'Regular User', + 'email' => 'member@example.com', + ]); + + $this->actingAs($admin) + ->get(route('admin.users.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('admin/users/Index') + ->where('users.0.email', $admin->email) + ->where('users.1.email', $member->email) + ->where('users.0.is_admin', true) + ->where('users.1.is_admin', false), + ); + } + + /** + * Ensure non-admin users cannot view the user management page. + */ + public function test_non_admin_cannot_view_the_user_management_page(): void + { + $member = User::factory()->create(); + + $this->actingAs($member) + ->get(route('admin.users.index')) + ->assertForbidden(); + } + + /** + * Ensure admins can update another user's admin access. + */ + public function test_admin_can_update_a_users_admin_access(): void + { + $admin = User::factory()->admin()->create(); + $member = User::factory()->create([ + 'name' => 'Regular User', + ]); + + $this->actingAs($admin) + ->patch(route('admin.users.update', $member), [ + 'is_admin' => true, + ]) + ->assertRedirect(route('admin.users.index')) + ->assertSessionHas('status', 'Regular User now has admin access.'); + + $this->assertTrue($member->refresh()->is_admin); + } + + /** + * Ensure non-admin users cannot update admin access. + */ + public function test_non_admin_cannot_update_a_users_admin_access(): void + { + $member = User::factory()->create(); + $otherUser = User::factory()->create([ + 'is_admin' => false, + ]); + + $this->actingAs($member) + ->patch(route('admin.users.update', $otherUser), [ + 'is_admin' => true, + ]) + ->assertForbidden(); + + $this->assertFalse($otherUser->refresh()->is_admin); + } +} diff --git a/tests/Feature/Models/UserTest.php b/tests/Feature/Models/UserTest.php new file mode 100644 index 0000000..d6d0eb8 --- /dev/null +++ b/tests/Feature/Models/UserTest.php @@ -0,0 +1,51 @@ +create(); + + $this->assertFalse($user->is_admin); + } + + /** + * Ensure the admin factory state creates admin users. + */ + public function test_admin_factory_state_creates_admin_users(): void + { + $user = User::factory()->admin()->create(); + + $this->assertTrue($user->is_admin); + } + + /** + * Ensure the user seeder creates both admin and regular users. + */ + public function test_user_seeder_creates_admin_and_regular_users(): void + { + $this->seed(UserSeeder::class); + + $this->assertDatabaseHas('users', [ + 'email' => 'admin@kanbangit.test', + 'is_admin' => true, + ]); + + $this->assertDatabaseHas('users', [ + 'email' => 'test@example.com', + 'is_admin' => false, + ]); + } +}