From a4873b8e900aad8473df1d6c91917e06df06c96e Mon Sep 17 00:00:00 2001 From: Saufi Date: Fri, 26 Jun 2026 19:10:31 +0800 Subject: [PATCH] profil --- app/Http/Controllers/Admin/UserController.php | 115 +++++++++++++++++ app/Http/Controllers/ProfileController.php | 49 ++++++++ app/Models/User.php | 15 +++ resources/views/admin/users/create.blade.php | 37 ++++++ resources/views/admin/users/edit.blade.php | 42 +++++++ resources/views/admin/users/index.blade.php | 37 ++++++ resources/views/layouts/app.blade.php | 11 +- resources/views/profile/edit.blade.php | 44 +++++++ routes/web.php | 15 +++ tests/Feature/UserManagementTest.php | 117 ++++++++++++++++++ 10 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Admin/UserController.php create mode 100644 app/Http/Controllers/ProfileController.php create mode 100644 resources/views/admin/users/create.blade.php create mode 100644 resources/views/admin/users/edit.blade.php create mode 100644 resources/views/admin/users/index.blade.php create mode 100644 resources/views/profile/edit.blade.php create mode 100644 tests/Feature/UserManagementTest.php diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..1bcb4fa --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,115 @@ +orderBy('name')->paginate(25); + $roles = User::ROLE_LABELS; + + return view('admin.users.index', compact('users', 'roles')); + } + + public function create(): View + { + $roles = User::ROLE_LABELS; + + return view('admin.users.create', compact('roles')); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'role' => ['required', Rule::in(array_keys(User::ROLE_LABELS))], + 'password' => ['required', 'confirmed', Password::min(8)], + ]); + + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + ]); + $user->syncRoles([$data['role']]); + + AuditLog::record('user.create', "Cipta pengguna: {$user->name} ({$user->email})", $user, ['role' => $data['role']]); + + return redirect()->route('admin.users.index')->with('success', "Pengguna '{$user->name}' dicipta."); + } + + public function edit(User $user): View + { + $roles = User::ROLE_LABELS; + + return view('admin.users.edit', compact('user', 'roles')); + } + + public function update(Request $request, User $user): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + 'role' => ['required', Rule::in(array_keys(User::ROLE_LABELS))], + 'password' => ['nullable', 'confirmed', Password::min(8)], + ]); + + // Jangan biar admin terakhir diturunkan peranannya (boleh kunci sistem) + if ($this->isLastAdmin($user) && $data['role'] !== User::ROLE_ADMIN) { + return back()->withInput()->with('error', 'Tidak boleh menukar peranan admin terakhir.'); + } + + $user->update([ + 'name' => $data['name'], + 'email' => $data['email'], + ]); + + if (! empty($data['password'])) { + $user->update(['password' => Hash::make($data['password'])]); + } + + $user->syncRoles([$data['role']]); + + AuditLog::record('user.update', "Kemaskini pengguna: {$user->name}", $user, [ + 'role' => $data['role'], + 'password_changed' => ! empty($data['password']), + ]); + + return redirect()->route('admin.users.index')->with('success', 'Maklumat pengguna dikemaskini.'); + } + + public function destroy(Request $request, User $user): RedirectResponse + { + if ($user->id === $request->user()->id) { + return back()->with('error', 'Anda tidak boleh memadam akaun sendiri.'); + } + + if ($this->isLastAdmin($user)) { + return back()->with('error', 'Tidak boleh memadam admin terakhir.'); + } + + $nama = $user->name; + $user->delete(); + AuditLog::record('user.delete', "Padam pengguna: {$nama}"); + + return redirect()->route('admin.users.index')->with('success', "Pengguna '{$nama}' dipadam."); + } + + private function isLastAdmin(User $user): bool + { + return $user->hasRole(User::ROLE_ADMIN) + && User::role(User::ROLE_ADMIN)->count() <= 1; + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..c4589f9 --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,49 @@ + auth()->user()]); + } + + public function update(Request $request): RedirectResponse + { + $user = $request->user(); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + ]); + + $user->update($data); + AuditLog::record('profile.update', 'Kemaskini profil sendiri', $user); + + return redirect()->route('profile.edit')->with('success', 'Profil dikemaskini.'); + } + + public function updatePassword(Request $request): RedirectResponse + { + $request->validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', 'confirmed', Password::min(8)], + ]); + + $request->user()->update([ + 'password' => Hash::make($request->input('password')), + ]); + AuditLog::record('profile.password', 'Tukar kata laluan sendiri', $request->user()); + + return redirect()->route('profile.edit')->with('success', 'Kata laluan ditukar.'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 87969d4..0079981 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,6 +17,21 @@ class User extends Authenticatable public const ROLE_KAUNTER = 'petugas_kaunter'; public const ROLE_CABUTAN = 'petugas_cabutan'; + /** Peranan yang boleh ditugaskan kepada pengguna + label paparan ringkas. */ + public const ROLE_LABELS = [ + self::ROLE_ADMIN => 'Admin', + self::ROLE_KAUNTER => 'Kaunter', + self::ROLE_CABUTAN => 'Cabutan', + ]; + + /** Label ringkas peranan utama pengguna ini (cth "Admin"). */ + public function roleLabel(): string + { + $role = $this->getRoleNames()->first(); + + return self::ROLE_LABELS[$role] ?? ($role ? \Illuminate\Support\Str::headline($role) : '-'); + } + protected $fillable = [ 'name', 'email', diff --git a/resources/views/admin/users/create.blade.php b/resources/views/admin/users/create.blade.php new file mode 100644 index 0000000..99c3713 --- /dev/null +++ b/resources/views/admin/users/create.blade.php @@ -0,0 +1,37 @@ +@extends('layouts.app') +@section('title', 'Pengguna Baru') + +@section('content') +
+
+
+ @csrf +
+
+ + @error('name')
{{ $message }}
@enderror
+
+ + @error('email')
{{ $message }}
@enderror
+
+ + @error('role')
{{ $message }}
@enderror
+
+ + @error('password')
{{ $message }}
@enderror
+
+
+
+
+ + Kembali +
+
+
+
+@endsection diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php new file mode 100644 index 0000000..47578fa --- /dev/null +++ b/resources/views/admin/users/edit.blade.php @@ -0,0 +1,42 @@ +@extends('layouts.app') +@section('title', 'Kemaskini Pengguna') + +@section('content') +
+
+
+ @csrf @method('PUT') +
+
+ + @error('name')
{{ $message }}
@enderror
+
+ + @error('email')
{{ $message }}
@enderror
+
+ + @error('role')
{{ $message }}
@enderror
+
+ +
+

Biarkan kosong jika tidak mahu menukar kata laluan.

+
+
+ + @error('password')
{{ $message }}
@enderror
+
+
+
+ +
+ + Kembali +
+
+
+
+@endsection diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..34a3e25 --- /dev/null +++ b/resources/views/admin/users/index.blade.php @@ -0,0 +1,37 @@ +@extends('layouts.app') +@section('title', 'Urus Pengguna') + +@section('content') +
+
Pengguna sistem mengikut peranan: Admin, Kaunter, Cabutan.
+ Pengguna Baru +
+ +
+
+ + + + @forelse($users as $user) + + + + + + + @empty + + @endforelse + +
NamaEmelPeranan
{{ $user->name }} + @if($user->id === auth()->id())Anda@endif{{ $user->email }}{{ $user->roleLabel() }} + +
+ @csrf @method('DELETE') + +
+
Tiada pengguna.
+
+ {{ $users->links() }} +
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 235388d..1421b25 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -59,6 +59,9 @@ Hadiah + + Pengguna + Audit Trail @@ -68,10 +71,10 @@ @endif
-
{{ $u?->name }}
-
- @foreach($u?->getRoleNames() ?? [] as $r){{ \Illuminate\Support\Str::headline($r) }}@endforeach -
+ +
{{ $u?->name }}
+
{{ $u?->roleLabel() }}
+
@csrf diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php new file mode 100644 index 0000000..1773356 --- /dev/null +++ b/resources/views/profile/edit.blade.php @@ -0,0 +1,44 @@ +@extends('layouts.app') +@section('title', 'Profil Saya') + +@section('content') +
+ +
+
Maklumat Profil
+ + @csrf @method('PUT') +
+
+ + @error('name')
{{ $message }}
@enderror
+
+ + @error('email')
{{ $message }}
@enderror
+
+
+
+
+ +
+ +
+
Tukar Kata Laluan
+
+ @csrf @method('PUT') +
+
+ + @error('current_password')
{{ $message }}
@enderror
+
+ + @error('password')
{{ $message }}
@enderror
+
+
+
+
+
+
+ +
+@endsection diff --git a/routes/web.php b/routes/web.php index 61e1de1..5b5f433 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,8 +7,10 @@ use App\Http\Controllers\Admin\SettingController; use App\Http\Controllers\AttendanceController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\CounterController; +use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\DrawController; +use App\Http\Controllers\ProfileController; use App\Http\Controllers\ReportController; use App\Models\User; use Illuminate\Support\Facades\Route; @@ -25,6 +27,11 @@ Route::get('/', fn () => redirect()->route('dashboard')); Route::middleware('auth')->group(function () { Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard'); + // ---- Profil sendiri (semua pengguna) ---- + Route::get('profil', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::put('profil', [ProfileController::class, 'update'])->name('profile.update'); + Route::put('profil/kata-laluan', [ProfileController::class, 'updatePassword'])->name('profile.password'); + // ---- Kaunter Kehadiran (admin + petugas kaunter) ---- Route::middleware('role:' . User::ROLE_ADMIN . '|' . User::ROLE_KAUNTER)->group(function () { Route::get('kaunter', [CounterController::class, 'index'])->name('counter.index'); @@ -67,5 +74,13 @@ Route::middleware('auth')->group(function () { Route::post('tetapan/reset', [SettingController::class, 'reset'])->name('settings.reset'); Route::get('audit', [AuditController::class, 'index'])->name('audit.index'); + + // ---- Urus Pengguna ---- + Route::get('pengguna', [UserController::class, 'index'])->name('users.index'); + Route::get('pengguna/cipta', [UserController::class, 'create'])->name('users.create'); + Route::post('pengguna', [UserController::class, 'store'])->name('users.store'); + Route::get('pengguna/{user}/edit', [UserController::class, 'edit'])->name('users.edit'); + Route::put('pengguna/{user}', [UserController::class, 'update'])->name('users.update'); + Route::delete('pengguna/{user}', [UserController::class, 'destroy'])->name('users.destroy'); }); }); diff --git a/tests/Feature/UserManagementTest.php b/tests/Feature/UserManagementTest.php new file mode 100644 index 0000000..8afa2c9 --- /dev/null +++ b/tests/Feature/UserManagementTest.php @@ -0,0 +1,117 @@ +seed(RoleUserSeeder::class); + + return User::where('email', 'admin@koipb.test')->first(); + } + + public function test_admin_can_create_user_with_role(): void + { + $this->actingAs($this->admin())->post(route('admin.users.store'), [ + 'name' => 'Pegawai Baru', + 'email' => 'baru@koipb.test', + 'role' => User::ROLE_KAUNTER, + 'password' => 'rahsia123', + 'password_confirmation' => 'rahsia123', + ])->assertRedirect(route('admin.users.index')); + + $user = User::where('email', 'baru@koipb.test')->first(); + $this->assertNotNull($user); + $this->assertTrue($user->hasRole(User::ROLE_KAUNTER)); + } + + public function test_admin_can_change_role_and_password(): void + { + $admin = $this->admin(); + $kaunter = User::where('email', 'kaunter@koipb.test')->first(); + + $this->actingAs($admin)->put(route('admin.users.update', $kaunter), [ + 'name' => $kaunter->name, + 'email' => $kaunter->email, + 'role' => User::ROLE_CABUTAN, + 'password' => 'katalaluanbaru', + 'password_confirmation' => 'katalaluanbaru', + ])->assertRedirect(route('admin.users.index')); + + $kaunter->refresh(); + $this->assertTrue($kaunter->hasRole(User::ROLE_CABUTAN)); + $this->assertFalse($kaunter->hasRole(User::ROLE_KAUNTER)); + $this->assertTrue(Hash::check('katalaluanbaru', $kaunter->password)); + } + + public function test_cannot_delete_self(): void + { + $admin = $this->admin(); + + $this->actingAs($admin)->delete(route('admin.users.destroy', $admin)) + ->assertSessionHas('error'); + + $this->assertDatabaseHas('users', ['id' => $admin->id]); + } + + public function test_cannot_demote_last_admin(): void + { + $admin = $this->admin(); + + $this->actingAs($admin)->put(route('admin.users.update', $admin), [ + 'name' => $admin->name, + 'email' => $admin->email, + 'role' => User::ROLE_KAUNTER, + ])->assertSessionHas('error'); + + $admin->refresh(); + $this->assertTrue($admin->hasRole(User::ROLE_ADMIN)); + } + + public function test_non_admin_cannot_access_user_management(): void + { + $this->seed(RoleUserSeeder::class); + $kaunter = User::where('email', 'kaunter@koipb.test')->first(); + + $this->actingAs($kaunter)->get(route('admin.users.index'))->assertForbidden(); + } + + public function test_user_can_change_own_password(): void + { + $this->seed(RoleUserSeeder::class); + $kaunter = User::where('email', 'kaunter@koipb.test')->first(); + + $this->actingAs($kaunter)->put(route('profile.password'), [ + 'current_password' => 'password', + 'password' => 'katalaluanbaru', + 'password_confirmation' => 'katalaluanbaru', + ])->assertRedirect(route('profile.edit')); + + $kaunter->refresh(); + $this->assertTrue(Hash::check('katalaluanbaru', $kaunter->password)); + } + + public function test_wrong_current_password_rejected(): void + { + $this->seed(RoleUserSeeder::class); + $kaunter = User::where('email', 'kaunter@koipb.test')->first(); + + $this->actingAs($kaunter)->put(route('profile.password'), [ + 'current_password' => 'salah', + 'password' => 'katalaluanbaru', + 'password_confirmation' => 'katalaluanbaru', + ])->assertSessionHasErrors('current_password'); + + $kaunter->refresh(); + $this->assertTrue(Hash::check('password', $kaunter->password)); + } +}