diff --git a/TODO.md b/TODO.md index 6014041..8b98b0f 100644 --- a/TODO.md +++ b/TODO.md @@ -5,15 +5,15 @@ ## Improvement [ ] mesyuarat anggota tertinggi [ ] penyata anggota -[ ] daftar anggota +[x] daftar anggota [ ] baki pinjaman - transaction history: tarik dari ubs [ ] pembiayaan anggota [ ] sumbangan -[ ] wasi/penama +[x] wasi/penama [ ] pendaftaran anggota/meneruskan anggota/pencen [ ] daftar lembaga (backdated) -[ ] boleh print semua borang -[ ] jana surat lepas lulus anggota +[x] boleh print semua borang +[x] jana surat lepas lulus anggota ## Present to Boss (2/7/2026) [x] discuss logo baru MyKOPKB diff --git a/be/.env.example b/be/.env.example index cc40028..6b7e521 100644 --- a/be/.env.example +++ b/be/.env.example @@ -3,6 +3,7 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost +FRONTEND_URL=http://localhost:5173 APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -87,3 +88,6 @@ AUTH_COOKIE_EXPOSE_TOKEN=false ACTIVE_ROLE_PREFER_MEMBER=true ACTIVE_ROLE_MEMBER_REDIRECT=/profile ACTIVE_ROLE_ADMIN_REDIRECT=/profile + +EMAIL_VERIFICATION_EXPIRE_MINUTES=60 +EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2 diff --git a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php index 932ce8e..543bf93 100644 --- a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php +++ b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php @@ -12,7 +12,6 @@ use Laravel\Fortify\Contracts\CreatesNewUsers; use Modules\Auth\Entities\User; use Modules\Role\Entities\Role; use Modules\User\Notifications\UserActivationNotification; -use Modules\Auth\Services\EmailVerificationOtpService; use Exception; class CreateNewUser implements CreatesNewUsers @@ -54,38 +53,6 @@ class CreateNewUser implements CreatesNewUsers $user->assignRole($role); } - app(EmailVerificationOtpService::class)->send($user); - - // Send notification to admins if user requires activation - if ($user->status === 'pending') { - $this->notifyAdminsForActivation($user); - } - return $user; } - - /** - * Notify admins about new user requiring activation - */ - private function notifyAdminsForActivation(User $newUser): void - { - try { - $adminRoles = ['PENTADBIR', 'PS 2 KJC', 'PS 2 ALAT']; - - // Get users with specific roles plus admins (PENTADBIR and DEVELOPER) - $adminUsers = $this->getUsersWithRolesAndAdmins($adminRoles); - - $sender = auth()->user() ?? $newUser; // Use current user as sender, or new user if no auth - - foreach ($adminUsers as $admin) { - try { - $admin->notify(new UserActivationNotification($newUser, $sender)); - } catch (Exception $e) { - Log::error('Failed to send user activation notification: '.$e->getMessage()); - } - } - } catch (Exception $e) { - Log::error('Failed to notify admins for user activation: '.$e->getMessage()); - } - } } diff --git a/be/Modules/Auth/Actions/Fortify/LoginResponse.php b/be/Modules/Auth/Actions/Fortify/LoginResponse.php index 5226f5a..4851320 100644 --- a/be/Modules/Auth/Actions/Fortify/LoginResponse.php +++ b/be/Modules/Auth/Actions/Fortify/LoginResponse.php @@ -3,39 +3,19 @@ namespace Modules\Auth\Actions\Fortify; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Auth; use Laravel\Fortify\Contracts\LoginResponse as ContractsLoginResponse; use Modules\Auth\Services\AuthSessionService; -use Modules\Auth\Services\EmailVerificationOtpService; class LoginResponse implements ContractsLoginResponse { public function __construct( protected AuthSessionService $authSession, - protected EmailVerificationOtpService $otpService, ) {} public function toResponse($request): JsonResponse { - $user = $request->user(); - - if (! $user->hasVerifiedEmail()) { - $this->otpService->send($user); - - Auth::guard(config('fortify.guard'))->logout(); - - return response()->json([ - 'success' => true, - 'message' => 'Sila semak e-mel anda untuk kod pengesahan 6 digit.', - 'data' => [ - 'email' => $user->email, - 'requires_email_verification' => true, - ], - ]); - } - return $this->authSession->createAuthResponse( - $user, + $request->user(), 'Login successful' ); } diff --git a/be/Modules/Auth/Actions/Fortify/RegisterResponse.php b/be/Modules/Auth/Actions/Fortify/RegisterResponse.php index c3d2487..be1850b 100644 --- a/be/Modules/Auth/Actions/Fortify/RegisterResponse.php +++ b/be/Modules/Auth/Actions/Fortify/RegisterResponse.php @@ -3,24 +3,21 @@ namespace Modules\Auth\Actions\Fortify; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Auth; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; +use Modules\Auth\Services\AuthSessionService; class RegisterResponse implements RegisterResponseContract { + public function __construct( + protected AuthSessionService $authSession, + ) {} + public function toResponse($request): JsonResponse { - $email = $request->user()?->email; - - Auth::guard(config('fortify.guard'))->logout(); - - return response()->json([ - 'success' => true, - 'message' => 'Pendaftaran berjaya. Sila semak e-mel anda untuk kod pengesahan 6 digit.', - 'data' => [ - 'email' => $email, - 'requires_email_verification' => true, - ], - ], 201); + return $this->authSession->createAuthResponse( + $request->user(), + 'Pendaftaran berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.', + 201 + ); } } diff --git a/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php b/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php deleted file mode 100644 index 2dd14a8..0000000 --- a/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php +++ /dev/null @@ -1,38 +0,0 @@ -mailMessage( - subject: 'Pengesahan E-mel - Kod OTP', - view: 'auth::emails.verification-otp', - data: [ - 'name' => $notifiable->name, - 'otp' => $this->otp, - 'minutes' => $minutes, - ], - ); - } -} diff --git a/be/Modules/Auth/Entities/EmailVerificationOtp.php b/be/Modules/Auth/Entities/EmailVerificationOtp.php deleted file mode 100644 index 62d4780..0000000 --- a/be/Modules/Auth/Entities/EmailVerificationOtp.php +++ /dev/null @@ -1,44 +0,0 @@ - 'datetime', - 'attempts' => 'integer', - ]; - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function isExpired(): bool - { - return $this->expires_at->isPast(); - } - - public function hasExceededMaxAttempts(): bool - { - return $this->attempts >= (int) config('auth.email_verification.max_attempts', 5); - } -} diff --git a/be/Modules/Auth/Entities/User.php b/be/Modules/Auth/Entities/User.php index f1b61c9..8819330 100644 --- a/be/Modules/Auth/Entities/User.php +++ b/be/Modules/Auth/Entities/User.php @@ -2,7 +2,8 @@ namespace Modules\Auth\Entities; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -22,12 +23,13 @@ use Modules\Role\Entities\Role; use Modules\User\Entities\Address; use Modules\User\Entities\BankDetail; use Modules\User\Entities\Employment; +use Modules\Auth\Notifications\VerifyEmailNotification; use Modules\User\Entities\Heir; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Modules\Auth\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes; + use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, MustVerifyEmailTrait, Notifiable, SoftDeletes; protected $table = 'users'; @@ -301,9 +303,9 @@ class User extends Authenticatable return $currentUser->hasPermissionTo('menyamar pengguna'); } - public function hasVerifiedEmail(): bool + public function sendEmailVerificationNotification(): void { - return $this->email_verified_at !== null; + $this->notify(new VerifyEmailNotification); } /** @@ -315,15 +317,11 @@ class User extends Authenticatable } /** - * Whether credentials are valid for issuing a session (includes verified pending users). + * Whether credentials are valid for issuing a session (includes pending users awaiting admin activation). */ public function canAuthenticate(): bool { - if ($this->canLogin()) { - return true; - } - - return $this->hasVerifiedEmail() && $this->status === 'pending'; + return in_array($this->status, ['active', 'pending'], true); } /** diff --git a/be/Modules/Auth/Http/Controllers/EmailVerificationController.php b/be/Modules/Auth/Http/Controllers/EmailVerificationController.php index ead22a3..dadf6d2 100644 --- a/be/Modules/Auth/Http/Controllers/EmailVerificationController.php +++ b/be/Modules/Auth/Http/Controllers/EmailVerificationController.php @@ -3,60 +3,66 @@ namespace Modules\Auth\Http\Controllers; use App\Http\Controllers\Controller; +use Illuminate\Auth\Events\Verified; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\URL; use Modules\Auth\Entities\User; -use Modules\Auth\Services\AuthSessionService; -use Modules\Auth\Services\EmailVerificationOtpService; class EmailVerificationController extends Controller { - public function __construct( - protected EmailVerificationOtpService $otpService, - protected AuthSessionService $authSession - ) {} - - public function verify(Request $request): JsonResponse + public function verify(Request $request, string $id, string $hash): RedirectResponse { - $validated = $request->validate([ - 'email' => ['required', 'string', 'email'], - 'otp' => ['required', 'string', 'digits:6'], - ]); - - $user = User::where('email', $validated['email'])->first(); - - if (! $user) { - return response()->json([ - 'success' => false, - 'message' => 'Kod OTP tidak sah.', - ], 422); + if (! URL::hasValidSignature($request)) { + return $this->redirectToFrontend('invalid'); } - $this->otpService->verify($user, $validated['otp']); + $user = User::find($id); - $user->refresh(); + if (! $user || ! hash_equals($hash, sha1($user->getEmailForVerification()))) { + return $this->redirectToFrontend('invalid'); + } - return $this->authSession->createAuthResponse( - $user, - 'E-mel anda telah berjaya disahkan. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.' - ); + if ($user->hasVerifiedEmail()) { + return $this->redirectToFrontend('already'); + } + + $user->markEmailAsVerified(); + + event(new Verified($user)); + + return $this->redirectToFrontend('success', $user); } - public function resend(Request $request): JsonResponse + public function send(Request $request): JsonResponse { - $validated = $request->validate([ - 'email' => ['required', 'string', 'email'], - ]); + $user = $request->user(); - $user = User::where('email', $validated['email'])->first(); - - if ($user && ! $user->hasVerifiedEmail()) { - $this->otpService->send($user); + if ($user->hasVerifiedEmail()) { + return response()->json([ + 'success' => true, + 'message' => 'E-mel anda telah disahkan.', + ]); } + $user->sendEmailVerificationNotification(); + return response()->json([ 'success' => true, - 'message' => 'Jika e-mel wujud dan belum disahkan, kod OTP baharu telah dihantar.', + 'message' => 'Pautan pengesahan e-mel telah dihantar.', ]); } + + protected function redirectToFrontend(string $status, ?User $user = null): RedirectResponse + { + $baseUrl = rtrim(config('user.frontend_url'), '/'); + $path = config('auth.verification.frontend_redirect_path', '/profile'); + + if ($user?->status === 'pending') { + $path = '/register?registered=success'; + } + + return redirect()->away("{$baseUrl}{$path}?verified={$status}"); + } } diff --git a/be/Modules/Auth/Notifications/VerifyEmailNotification.php b/be/Modules/Auth/Notifications/VerifyEmailNotification.php new file mode 100644 index 0000000..1506daa --- /dev/null +++ b/be/Modules/Auth/Notifications/VerifyEmailNotification.php @@ -0,0 +1,51 @@ +mailMessage( + subject: 'Pengesahan E-mel - MyKOPKB', + view: 'auth::emails.verify-email', + data: [ + 'name' => $notifiable->name, + 'verificationUrl' => $this->verificationUrl($notifiable), + 'expireMinutes' => $expireMinutes, + ], + ); + } + + protected function verificationUrl(User $notifiable): string + { + $expireMinutes = (int) config('auth.verification.expire_minutes', 60); + + return URL::temporarySignedRoute( + 'verification.verify', + Carbon::now()->addMinutes($expireMinutes), + [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + ] + ); + } +} diff --git a/be/Modules/Auth/Routes/api.php b/be/Modules/Auth/Routes/api.php index ea3eb18..9a695f6 100644 --- a/be/Modules/Auth/Routes/api.php +++ b/be/Modules/Auth/Routes/api.php @@ -11,10 +11,9 @@ use Laravel\Fortify\Http\Controllers\RegisteredUserController; Route::post('/register', [RegisteredUserController::class, 'store']); Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools'); -Route::post('/verify-email', [EmailVerificationController::class, 'verify']) - ->middleware('throttle:email-verification'); -Route::post('/verify-email/resend', [EmailVerificationController::class, 'resend']) - ->middleware('throttle:email-verification-resend'); +Route::get('/email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify']) + ->middleware('throttle:email-verification') + ->name('verification.verify'); Route::post('/forgot-password', [PasswordResetController::class, 'requestOtp']) ->middleware('throttle:password-reset-request'); @@ -30,4 +29,8 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () { // logout Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']); + + // optional email verification (post-login) + Route::post('/email/verification-notification', [EmailVerificationController::class, 'send']) + ->middleware('throttle:email-verification-resend'); }); \ No newline at end of file diff --git a/be/Modules/Auth/Services/EmailVerificationOtpService.php b/be/Modules/Auth/Services/EmailVerificationOtpService.php deleted file mode 100644 index ed823d7..0000000 --- a/be/Modules/Auth/Services/EmailVerificationOtpService.php +++ /dev/null @@ -1,98 +0,0 @@ -hasVerifiedEmail()) { - return; - } - - $otp = $this->generateOtp(); - - EmailVerificationOtp::query() - ->where('user_id', $user->id) - ->delete(); - - EmailVerificationOtp::create([ - 'user_id' => $user->id, - 'code' => Hash::make($otp), - 'expires_at' => now()->addMinutes($this->expiryMinutes()), - 'attempts' => 0, - ]); - - $user->notify(new EmailVerificationOtpEmail($otp)); - } - - public function verify(User $user, string $otp): void - { - if ($user->hasVerifiedEmail()) { - throw ValidationException::withMessages([ - 'email' => ['E-mel anda telah disahkan.'], - ]); - } - - $record = EmailVerificationOtp::query() - ->where('user_id', $user->id) - ->latest() - ->first(); - - if (! $record) { - throw ValidationException::withMessages([ - 'otp' => ['Kod OTP tidak dijumpai. Sila minta kod baharu.'], - ]); - } - - if ($record->isExpired()) { - $record->delete(); - - throw ValidationException::withMessages([ - 'otp' => ['Kod OTP telah tamat tempoh. Sila minta kod baharu.'], - ]); - } - - if ($record->hasExceededMaxAttempts()) { - $record->delete(); - - throw ValidationException::withMessages([ - 'otp' => ['Terlalu banyak percubaan. Sila minta kod baharu.'], - ]); - } - - if (! Hash::check($otp, $record->code)) { - $record->increment('attempts'); - - if ($record->fresh()->hasExceededMaxAttempts()) { - $record->delete(); - } - - throw ValidationException::withMessages([ - 'otp' => ['Kod OTP tidak sah.'], - ]); - } - - $user->forceFill(['email_verified_at' => now()])->save(); - - EmailVerificationOtp::query() - ->where('user_id', $user->id) - ->delete(); - } - - protected function generateOtp(): string - { - return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); - } - - protected function expiryMinutes(): int - { - return (int) config('auth.email_verification.expiry_minutes', 10); - } -} diff --git a/be/Modules/Auth/resources/views/emails/verification-otp.blade.php b/be/Modules/Auth/resources/views/emails/verification-otp.blade.php deleted file mode 100644 index dcaf385..0000000 --- a/be/Modules/Auth/resources/views/emails/verification-otp.blade.php +++ /dev/null @@ -1,21 +0,0 @@ -@component('mail::message') -@include('emails.partials.header') - -# Pengesahan E-mel - -Assalamualaikum **{{ $name }}**, - -Terima kasih kerana mendaftar. Gunakan kod OTP di bawah untuk mengesahkan alamat e-mel anda. - -@component('mail::panel') -
- Kod OTP 6 digit telah dihantar ke e-mel anda. -
- -