This commit is contained in:
Saufi
2026-06-26 19:10:31 +08:00
parent 1de6355e70
commit a4873b8e90
10 changed files with 478 additions and 4 deletions

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class UserController extends Controller
{
public function index(): View
{
$users = User::with('roles')->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;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Models\AuditLog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class ProfileController extends Controller
{
public function edit(): View
{
return view('profile.edit', ['user' => 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.');
}
}

View File

@@ -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',

View File

@@ -0,0 +1,37 @@
@extends('layouts.app')
@section('title', 'Pengguna Baru')
@section('content')
<div class="row justify-content-center"><div class="col-lg-7">
<div class="card"><div class="card-body">
<form method="POST" action="{{ route('admin.users.store') }}">
@csrf
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Nama *</label>
<input name="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name') }}" required>
@error('name')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Emel *</label>
<input type="email" name="email" class="form-control @error('email') is-invalid @enderror" value="{{ old('email') }}" required>
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Peranan *</label>
<select name="role" class="form-select @error('role') is-invalid @enderror" required>
<option value=""> Pilih peranan </option>
@foreach($roles as $value => $label)
<option value="{{ $value }}" @selected(old('role') === $value)>{{ $label }}</option>
@endforeach
</select>
@error('role')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Kata Laluan *</label>
<input type="password" name="password" class="form-control @error('password') is-invalid @enderror" required>
@error('password')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Sahkan Kata Laluan *</label>
<input type="password" name="password_confirmation" class="form-control" required></div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-teal"><i class="bi bi-save"></i> Simpan</button>
<a href="{{ route('admin.users.index') }}" class="btn btn-light">Kembali</a>
</div>
</form>
</div></div>
</div></div>
@endsection

View File

@@ -0,0 +1,42 @@
@extends('layouts.app')
@section('title', 'Kemaskini Pengguna')
@section('content')
<div class="row justify-content-center"><div class="col-lg-7">
<div class="card"><div class="card-body">
<form method="POST" action="{{ route('admin.users.update', $user) }}">
@csrf @method('PUT')
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Nama *</label>
<input name="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name', $user->name) }}" required>
@error('name')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Emel *</label>
<input type="email" name="email" class="form-control @error('email') is-invalid @enderror" value="{{ old('email', $user->email) }}" required>
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Peranan *</label>
<select name="role" class="form-select @error('role') is-invalid @enderror" required>
@foreach($roles as $value => $label)
<option value="{{ $value }}" @selected(old('role', $user->getRoleNames()->first()) === $value)>{{ $label }}</option>
@endforeach
</select>
@error('role')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
</div>
<hr class="my-4">
<p class="text-muted small mb-3"><i class="bi bi-key"></i> Biarkan kosong jika tidak mahu menukar kata laluan.</p>
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Kata Laluan Baru</label>
<input type="password" name="password" class="form-control @error('password') is-invalid @enderror">
@error('password')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Sahkan Kata Laluan</label>
<input type="password" name="password_confirmation" class="form-control"></div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-teal"><i class="bi bi-save"></i> Simpan</button>
<a href="{{ route('admin.users.index') }}" class="btn btn-light">Kembali</a>
</div>
</form>
</div></div>
</div></div>
@endsection

View File

@@ -0,0 +1,37 @@
@extends('layouts.app')
@section('title', 'Urus Pengguna')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="text-muted">Pengguna sistem mengikut peranan: Admin, Kaunter, Cabutan.</div>
<a href="{{ route('admin.users.create') }}" class="btn btn-teal"><i class="bi bi-person-plus"></i> Pengguna Baru</a>
</div>
<div class="card"><div class="card-body">
<div class="table-responsive">
<table class="table table-koipb table-hover align-middle">
<thead><tr><th>Nama</th><th>Emel</th><th>Peranan</th><th></th></tr></thead>
<tbody>
@forelse($users as $user)
<tr>
<td class="fw-semibold">{{ $user->name }}
@if($user->id === auth()->id())<span class="badge bg-teal ms-1">Anda</span>@endif</td>
<td>{{ $user->email }}</td>
<td><span class="badge bg-dark2">{{ $user->roleLabel() }}</span></td>
<td class="text-end">
<a href="{{ route('admin.users.edit', $user) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-pencil"></i></a>
<form action="{{ route('admin.users.destroy', $user) }}" method="POST" class="d-inline" onsubmit="return confirm('Padam pengguna ini?')">
@csrf @method('DELETE')
<button class="btn btn-sm btn-outline-danger" @disabled($user->id === auth()->id())><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
@empty
<tr><td colspan="4" class="text-center text-muted py-4">Tiada pengguna.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{ $users->links() }}
</div></div>
@endsection

View File

@@ -59,6 +59,9 @@
<a href="{{ route('admin.prizes.index') }}" class="{{ request()->routeIs('admin.prizes.*') ? 'active' : '' }}">
<span class="ico"><i class="bi bi-gift"></i></span> Hadiah
</a>
<a href="{{ route('admin.users.index') }}" class="{{ request()->routeIs('admin.users.*') ? 'active' : '' }}">
<span class="ico"><i class="bi bi-person-gear"></i></span> Pengguna
</a>
<a href="{{ route('admin.audit.index') }}" class="{{ request()->routeIs('admin.audit.*') ? 'active' : '' }}">
<span class="ico"><i class="bi bi-shield-check"></i></span> Audit Trail
</a>
@@ -68,10 +71,10 @@
@endif
</nav>
<div class="sb-foot">
<div class="text-white-50 small">{{ $u?->name }}</div>
<div class="small mb-2" style="color:var(--koipb-teal-light)">
@foreach($u?->getRoleNames() ?? [] as $r){{ \Illuminate\Support\Str::headline($r) }}@endforeach
</div>
<a href="{{ route('profile.edit') }}" class="text-decoration-none d-block mb-2">
<div class="text-white small"><i class="bi bi-person-circle"></i> {{ $u?->name }}</div>
<div class="small" style="color:var(--koipb-teal-light)">{{ $u?->roleLabel() }}</div>
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button class="btn btn-sm btn-outline-light w-100"><i class="bi bi-box-arrow-right"></i> Log Keluar</button>

View File

@@ -0,0 +1,44 @@
@extends('layouts.app')
@section('title', 'Profil Saya')
@section('content')
<div class="row justify-content-center g-3"><div class="col-lg-7">
<div class="card mb-3"><div class="card-body">
<h5 class="fw-bold"><i class="bi bi-person-circle text-teal"></i> Maklumat Profil</h5>
<form method="POST" action="{{ route('profile.update') }}">
@csrf @method('PUT')
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Nama *</label>
<input name="name" class="form-control @error('name') is-invalid @enderror" value="{{ old('name', $user->name) }}" required>
@error('name')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Emel *</label>
<input type="email" name="email" class="form-control @error('email') is-invalid @enderror" value="{{ old('email', $user->email) }}" required>
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Peranan</label>
<input class="form-control" value="{{ $user->roleLabel() }}" disabled></div>
</div>
<div class="mt-4"><button class="btn btn-teal"><i class="bi bi-save"></i> Simpan Profil</button></div>
</form>
</div></div>
<div class="card"><div class="card-body">
<h5 class="fw-bold"><i class="bi bi-key text-teal"></i> Tukar Kata Laluan</h5>
<form method="POST" action="{{ route('profile.password') }}">
@csrf @method('PUT')
<div class="row g-3">
<div class="col-12"><label class="form-label">Kata Laluan Semasa *</label>
<input type="password" name="current_password" class="form-control @error('current_password') is-invalid @enderror" required>
@error('current_password')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Kata Laluan Baru *</label>
<input type="password" name="password" class="form-control @error('password') is-invalid @enderror" required>
@error('password')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Sahkan Kata Laluan Baru *</label>
<input type="password" name="password_confirmation" class="form-control" required></div>
</div>
<div class="mt-4"><button class="btn btn-teal"><i class="bi bi-shield-lock"></i> Tukar Kata Laluan</button></div>
</form>
</div></div>
</div></div>
@endsection

View File

@@ -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');
});
});

View File

@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Database\Seeders\RoleUserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class UserManagementTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
$this->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));
}
}