diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..5e7bbf0 --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,49 @@ + User::orderBy('name')->get(), + ]); + } + + public function create(): View + { + return view('admin.users.create'); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'username' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('users', 'username')], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'role' => ['nullable', Rule::in(['user', 'admin'])], + ]); + + User::create([ + 'name' => $validated['name'], + 'username' => $validated['username'], + 'email' => $validated['email'], + 'password' => Hash::make($validated['password']), + 'role' => $validated['role'] ?? 'user', + ]); + + return redirect() + ->route('admin.users.index') + ->with('status', 'User baru berjaya ditambah.'); + } +} diff --git a/app/Http/Controllers/PasswordResetController.php b/app/Http/Controllers/PasswordResetController.php new file mode 100644 index 0000000..754c431 --- /dev/null +++ b/app/Http/Controllers/PasswordResetController.php @@ -0,0 +1,95 @@ +validate([ + 'email' => ['required', 'email'], + ]); + + $user = User::where('email', $validated['email'])->first(); + + if ($user) { + $token = Str::random(64); + + DB::table('password_reset_tokens')->updateOrInsert([ + 'email' => $user->email, + ], [ + 'token' => Hash::make($token), + 'created_at' => now(), + ]); + + $url = route('password.reset', [ + 'token' => $token, + 'email' => $user->email, + ]); + + Mail::raw("Klik pautan ini untuk reset kata laluan:\n\n{$url}\n\nPautan sah selama 60 minit.", function ($message) use ($user): void { + $message->to($user->email) + ->subject('Reset Kata Laluan RateMas'); + }); + } + + return back()->with('status', 'Jika emel wujud, pautan reset kata laluan telah dihantar.'); + } + + public function edit(Request $request, string $token): View + { + return view('auth.reset-password', [ + 'token' => $token, + 'email' => $request->query('email'), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'token' => ['required', 'string'], + 'email' => ['required', 'email'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $record = DB::table('password_reset_tokens') + ->where('email', $validated['email']) + ->first(); + + if (! $record || ! Hash::check($validated['token'], $record->token) || Carbon::parse($record->created_at)->lt(now()->subMinutes(60))) { + return back() + ->withErrors(['email' => 'Token reset tidak sah atau telah tamat tempoh.']) + ->withInput($request->only('email')); + } + + $user = User::where('email', $validated['email'])->first(); + if (! $user) { + return back() + ->withErrors(['email' => 'Emel tidak dijumpai.']) + ->withInput($request->only('email')); + } + + $user->forceFill([ + 'password' => Hash::make($validated['password']), + ])->save(); + + DB::table('password_reset_tokens')->where('email', $validated['email'])->delete(); + + return redirect()->route('login')->with('status', 'Password berjaya ditukar. Sila login semula.'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..18906ea --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,48 @@ + $request->user(), + ]); + } + + public function updateEmail(Request $request): RedirectResponse + { + $user = $request->user(); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + ]); + + $user->forceFill([ + 'email' => $validated['email'], + ])->save(); + + return back()->with('status', 'Emel berjaya dikemaskini.'); + } + + public function updatePassword(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $request->user()->forceFill([ + 'password' => Hash::make($validated['password']), + ])->save(); + + return back()->with('status', 'Password berjaya dikemaskini.'); + } +} diff --git a/resources/views/admin/users/create.blade.php b/resources/views/admin/users/create.blade.php new file mode 100644 index 0000000..969592e --- /dev/null +++ b/resources/views/admin/users/create.blade.php @@ -0,0 +1,42 @@ +@extends('layouts.app') + +@section('content') +
+

Tambah User

+

User baru akan menjadi user biasa secara default. Pilih role admin jika perlu.

+ + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf + + + + + + + + + + + + + + + + + + + +
+ + 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..08e48a1 --- /dev/null +++ b/resources/views/admin/users/index.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.app') + +@section('content') +
+

Users

+

Senarai akaun pengguna sistem.

+ + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+ Tambah User +
+ +
+ + + + + + + + + + + @foreach ($users as $user) + + + + + + + @endforeach + +
NamaUsernameEmelRole
{{ $user->name }}{{ $user->username }}{{ $user->email }}{{ $user->role }}
+
+
+@endsection diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..2b161c7 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,28 @@ +@extends('layouts.app') + +@section('content') +
+

Lupa Kata Laluan

+

Masukkan emel akaun untuk menerima pautan reset kata laluan.

+ + @if (session('status')) +
{{ session('status') }}
+ @endif + + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf + + + + +
+ + Kembali Login +
+
+
+@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 28bef8d..7f4b2a0 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -4,6 +4,10 @@

LOGIN

+ @if (session('status')) +
{{ session('status') }}
+ @endif + @error('username')
{{ $message }}
@enderror @@ -16,5 +20,9 @@ + +

+ Lupa kata laluan? +

@endsection diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..e4c1fff --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,31 @@ +@extends('layouts.app') + +@section('content') +
+

Reset Kata Laluan

+

Masukkan password baru untuk akaun anda.

+ + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf + + + + + + + + + + + + +
+ +
+
+
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 4da12ef..8706af6 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -27,6 +27,9 @@ .checkbox-row { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; } .checkbox-row input { width: auto; margin: 0; } .checkbox-row label { margin: 0; font-weight: 400; } + .data-table { width: 100%; border-collapse: collapse; font-size: 14px; } + .data-table th, .data-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #d8dde3; } + .data-table th { background: #eef2f6; font-size: 13px; } .error { color: #b00020; margin-bottom: 12px; font-size: 14px; } .logout-form { margin: 0; } .logout-form button { background: transparent; padding: 8px 0; font-weight: 700; } @@ -45,7 +48,9 @@ @auth
Carian + Profile @if (auth()->user()->isAdmin()) + Users Upload RateMas @endif
diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php new file mode 100644 index 0000000..cb057c3 --- /dev/null +++ b/resources/views/profile/edit.blade.php @@ -0,0 +1,49 @@ +@extends('layouts.app') + +@section('content') +
+

Profile

+

Kemaskini emel dan password akaun.

+ + @if (session('status')) +
{{ session('status') }}
+ @endif + + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + + + @csrf + @method('PUT') + + + + +
+ +
+ + +
+ +
+ @csrf + @method('PUT') + + + + + + + + + + +
+ + Kembali +
+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 07a9796..03c9d1e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,12 +2,19 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\Admin\RateMasUploadController; +use App\Http\Controllers\Admin\UserController; +use App\Http\Controllers\PasswordResetController; +use App\Http\Controllers\ProfileController; use App\Http\Controllers\RateMasController; use Illuminate\Support\Facades\Route; Route::middleware('guest')->group(function () { Route::get('/login', [AuthController::class, 'showLogin'])->name('login'); Route::post('/login', [AuthController::class, 'login'])->name('login.store'); + Route::get('/forgot-password', [PasswordResetController::class, 'create'])->name('password.request'); + Route::post('/forgot-password', [PasswordResetController::class, 'store'])->name('password.email'); + Route::get('/reset-password/{token}', [PasswordResetController::class, 'edit'])->name('password.reset'); + Route::post('/reset-password', [PasswordResetController::class, 'update'])->name('password.update'); }); Route::post('/logout', [AuthController::class, 'logout']) @@ -16,6 +23,9 @@ Route::post('/logout', [AuthController::class, 'logout']) Route::middleware('auth')->group(function () { Route::redirect('/', '/tables'); + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::put('/profile/email', [ProfileController::class, 'updateEmail'])->name('profile.email.update'); + Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password.update'); Route::get('/tables', [RateMasController::class, 'index'])->name('tables.index'); Route::get('/records/search', [RateMasController::class, 'lookup'])->name('ratemas.lookup'); Route::get('/tables/{table}/search', [RateMasController::class, 'search'])->name('ratemas.search'); @@ -24,5 +34,8 @@ Route::middleware('auth')->group(function () { Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () { Route::get('/ratemas/upload', [RateMasUploadController::class, 'create'])->name('ratemas-upload.create'); Route::post('/ratemas/upload', [RateMasUploadController::class, 'store'])->name('ratemas-upload.store'); + Route::get('/users', [UserController::class, 'index'])->name('users.index'); + Route::get('/users/create', [UserController::class, 'create'])->name('users.create'); + Route::post('/users', [UserController::class, 'store'])->name('users.store'); }); }); diff --git a/tests/Feature/RateMasWorkflowTest.php b/tests/Feature/RateMasWorkflowTest.php index f110161..73e4487 100644 --- a/tests/Feature/RateMasWorkflowTest.php +++ b/tests/Feature/RateMasWorkflowTest.php @@ -6,7 +6,9 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use Tests\TestCase; class RateMasWorkflowTest extends TestCase @@ -87,6 +89,18 @@ class RateMasWorkflowTest extends TestCase $this->get('/admin/ratemas/upload')->assertForbidden(); } + public function test_regular_user_cannot_open_admin_user_management(): void + { + $this->actingAs(User::create([ + 'name' => 'User Cukai', + 'username' => 'cukai', + 'email' => 'cukai@example.local', + 'password' => '123', + ])); + + $this->get('/admin/users')->assertForbidden(); + } + public function test_admin_can_upload_csv_and_create_year_table(): void { $this->actingAs(User::create([ @@ -116,6 +130,116 @@ class RateMasWorkflowTest extends TestCase ->assertSee('Siti Aminah'); } + public function test_authenticated_user_can_update_email_and_password(): void + { + $user = User::create([ + 'name' => 'User Cukai', + 'username' => 'cukai', + 'email' => 'cukai@example.local', + 'password' => '12345678', + ]); + + $this->actingAs($user); + + $this->put('/profile/email', [ + 'email' => 'baru@example.local', + ])->assertRedirect(); + + $this->assertSame('baru@example.local', $user->fresh()->email); + + $this->put('/profile/password', [ + 'current_password' => '12345678', + 'password' => 'password-baru', + 'password_confirmation' => 'password-baru', + ])->assertRedirect(); + + $this->assertTrue(Hash::check('password-baru', $user->fresh()->password)); + } + + public function test_guest_can_request_and_complete_password_reset(): void + { + $user = User::create([ + 'name' => 'User Cukai', + 'username' => 'cukai', + 'email' => 'cukai@example.local', + 'password' => '12345678', + ]); + + $this->post('/forgot-password', [ + 'email' => $user->email, + ])->assertRedirect(); + + $this->assertDatabaseHas('password_reset_tokens', [ + 'email' => $user->email, + ]); + + $token = Str::random(64); + DB::table('password_reset_tokens')->updateOrInsert([ + 'email' => $user->email, + ], [ + 'token' => Hash::make($token), + 'created_at' => now(), + ]); + + $this->post('/reset-password', [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'reset-baru', + 'password_confirmation' => 'reset-baru', + ])->assertRedirect('/login'); + + $this->assertTrue(Hash::check('reset-baru', $user->fresh()->password)); + $this->assertDatabaseMissing('password_reset_tokens', [ + 'email' => $user->email, + ]); + } + + public function test_admin_can_create_regular_user_by_default(): void + { + $this->actingAs(User::create([ + 'name' => 'Admin', + 'username' => 'admin', + 'role' => 'admin', + 'email' => 'admin@example.local', + 'password' => 'admin123', + ])); + + $this->post('/admin/users', [ + 'name' => 'User Baru', + 'username' => 'userbaru', + 'email' => 'userbaru@example.local', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ])->assertRedirect('/admin/users'); + + $user = User::where('username', 'userbaru')->firstOrFail(); + + $this->assertSame('user', $user->role); + $this->assertTrue(Hash::check('password123', $user->password)); + } + + public function test_admin_can_create_new_admin_user(): void + { + $this->actingAs(User::create([ + 'name' => 'Admin', + 'username' => 'admin', + 'role' => 'admin', + 'email' => 'admin@example.local', + 'password' => 'admin123', + ])); + + $this->post('/admin/users', [ + 'name' => 'Admin Baru', + 'username' => 'adminbaru', + 'email' => 'adminbaru@example.local', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'role' => 'admin', + ])->assertRedirect('/admin/users'); + + $this->assertTrue(User::where('username', 'adminbaru')->firstOrFail()->isAdmin()); + } + private function createRateMasTable(string $table): void { Schema::create($table, function ($table) {