DONE: layout letter with letterhead and footer, notification for newly...

This commit is contained in:
ISMAIL MASSERAN
2026-06-28 02:16:49 +00:00
parent 94ecbe5887
commit 034ecf947f
400 changed files with 29802 additions and 5446 deletions
@@ -2,12 +2,14 @@
namespace Modules\Auth\Emails;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class EmailVerificationOtpEmail extends Notification
{
use BuildsMailMessage;
use Queueable;
public function __construct(
@@ -23,13 +25,14 @@ class EmailVerificationOtpEmail extends Notification
{
$minutes = (int) config('auth.email_verification.expiry_minutes', 10);
return (new MailMessage)
->subject('Pengesahan E-mel - Kod OTP')
->markdown('auth::emails.verification-otp', [
return $this->mailMessage(
subject: 'Pengesahan E-mel - Kod OTP',
view: 'auth::emails.verification-otp',
data: [
'name' => $notifiable->name,
'otp' => $this->otp,
'minutes' => $minutes,
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
],
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\Auth\Emails;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PasswordResetOtpEmail extends Notification
{
use BuildsMailMessage;
use Queueable;
public function __construct(
protected string $otp
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$minutes = (int) config('auth.password_reset.expiry_minutes', 10);
return $this->mailMessage(
subject: 'Reset Kata Laluan - Kod OTP',
view: 'auth::emails.password-reset-otp',
data: [
'name' => $notifiable->name,
'otp' => $this->otp,
'minutes' => $minutes,
],
);
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\Auth\Entities;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PasswordResetOtp extends Model
{
use HasUuids;
protected $table = 'password_reset_otps';
protected $fillable = [
'user_id',
'code',
'expires_at',
'attempts',
];
protected function casts(): array
{
return [
'expires_at' => '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.password_reset.max_attempts', 5);
}
}
+39 -2
View File
@@ -16,14 +16,17 @@ use Spatie\Permission\PermissionRegistrar;
use Spatie\Permission\Traits\HasPermissions;
use Spatie\Permission\Traits\HasRoles;
use App\Services\ActiveRoleService;
use App\Traits\HasVisibility;
use Lab404\Impersonate\Models\Impersonate;
use Modules\Role\Entities\Role;
use Modules\User\Entities\Address;
use Modules\User\Entities\BankDetail;
use Modules\User\Entities\Employment;
use Modules\User\Entities\Heir;
class User extends Authenticatable
{
/** @use HasFactory<\Modules\Auth\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes, HasVisibility;
use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes;
protected $table = 'users';
@@ -44,6 +47,13 @@ class User extends Authenticatable
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
'gender',
'marriage_status',
'member_number',
'member_type',
'join_date',
'birth_date',
'birth_place',
];
/**
@@ -70,6 +80,13 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
'password' => 'hashed',
'status' => 'string',
'gender' => 'string',
'marriage_status' => 'string',
'member_number' => 'integer',
'member_type' => 'string',
'join_date' => 'date',
'birth_date' => 'date',
'birth_place' => 'string',
];
}
@@ -82,6 +99,26 @@ class User extends Authenticatable
return ActiveRoleService::getActiveRole($this);
}
public function addresses()
{
return $this->hasMany(Address::class);
}
public function employments()
{
return $this->hasMany(Employment::class);
}
public function bankDetails()
{
return $this->hasMany(BankDetail::class);
}
public function heirs()
{
return $this->hasMany(Heir::class);
}
public function hasPermissionAction($action): bool
{
$role = $this->activeRole();
@@ -0,0 +1,63 @@
<?php
namespace Modules\Auth\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Auth\Entities\User;
use Modules\Auth\Services\PasswordResetOtpService;
class PasswordResetController extends Controller
{
public function __construct(
protected PasswordResetOtpService $passwordResetOtpService
) {}
public function requestOtp(Request $request): JsonResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'email'],
]);
$user = User::where('email', $validated['email'])->first();
if ($user) {
$this->passwordResetOtpService->send($user);
}
return response()->json([
'success' => true,
'message' => 'Jika e-mel wujud dalam sistem, kod OTP untuk reset kata laluan telah dihantar.',
]);
}
public function reset(Request $request): JsonResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'email'],
'otp' => ['required', 'string', 'digits:6'],
'password' => ['required', 'string', 'confirmed'],
]);
$user = User::where('email', $validated['email'])->first();
if (! $user) {
return response()->json([
'success' => false,
'message' => 'Kod OTP tidak sah.',
], 422);
}
$this->passwordResetOtpService->reset(
$user,
$validated['otp'],
$request->only(['password', 'password_confirmation']),
);
return response()->json([
'success' => true,
'message' => 'Kata laluan anda telah berjaya ditetapkan semula. Sila log masuk.',
]);
}
}
+6
View File
@@ -2,6 +2,7 @@
use Illuminate\Support\Facades\Route;
use Modules\Auth\Http\Controllers\EmailVerificationController;
use Modules\Auth\Http\Controllers\PasswordResetController;
use Modules\Auth\Http\Controllers\SessionController;
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
@@ -15,6 +16,11 @@ Route::post('/verify-email', [EmailVerificationController::class, 'verify'])
Route::post('/verify-email/resend', [EmailVerificationController::class, 'resend'])
->middleware('throttle:email-verification-resend');
Route::post('/forgot-password', [PasswordResetController::class, 'requestOtp'])
->middleware('throttle:password-reset-request');
Route::post('/reset-password', [PasswordResetController::class, 'reset'])
->middleware('throttle:password-reset');
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
// get current user
Route::get('/me', [SessionController::class, 'currentUser']);
@@ -0,0 +1,99 @@
<?php
namespace Modules\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Modules\Auth\Actions\Fortify\PasswordValidationRules;
use Modules\Auth\Emails\PasswordResetOtpEmail;
use Modules\Auth\Entities\PasswordResetOtp;
use Modules\Auth\Entities\User;
class PasswordResetOtpService
{
use PasswordValidationRules;
public function send(User $user): void
{
$otp = $this->generateOtp();
PasswordResetOtp::query()
->where('user_id', $user->id)
->delete();
PasswordResetOtp::create([
'user_id' => $user->id,
'code' => Hash::make($otp),
'expires_at' => now()->addMinutes($this->expiryMinutes()),
'attempts' => 0,
]);
$user->notify(new PasswordResetOtpEmail($otp));
}
public function reset(User $user, string $otp, array $input): void
{
validator($input, [
'password' => $this->passwordRules(),
])->validate();
$record = PasswordResetOtp::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([
'password' => Hash::make($input['password']),
])->save();
$user->tokens()->delete();
PasswordResetOtp::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.password_reset.expiry_minutes', 10);
}
}
@@ -5,6 +5,10 @@ namespace Modules\Auth\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Modules\User\Transformers\AddressResource;
use Modules\User\Transformers\BankDetailResource;
use Modules\User\Transformers\EmploymentResource;
use Modules\User\Transformers\HeirResource;
class UserResource extends JsonResource
{
@@ -28,9 +32,20 @@ class UserResource extends JsonResource
'two_factor_recovery_codes' => $this->two_factor_recovery_codes,
'two_factor_confirmed_at' => $this->two_factor_confirmed_at,
'email_verified_at' => $this->email_verified_at,
'gender' => $this->gender,
'marriage_status' => $this->marriage_status,
'member_number' => $this->member_number,
'member_type' => $this->member_type,
'join_date' => $this->join_date,
'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'deleted_at' => $this->deleted_at,
'addresses' => AddressResource::collection($this->whenLoaded('addresses')),
'employments' => EmploymentResource::collection($this->whenLoaded('employments')),
'bank_details' => BankDetailResource::collection($this->whenLoaded('bankDetails')),
'heirs' => HeirResource::collection($this->whenLoaded('heirs')),
'roles' => $this->whenLoaded('roles', function () {
return $this->roles->map(function ($role) {
return [
@@ -0,0 +1,21 @@
@component('mail::message')
@include('emails.partials.header')
# Reset Kata Laluan
Assalamualaikum **{{ $name }}**,
Kami menerima permintaan untuk menetapkan semula kata laluan akaun anda. Gunakan kod OTP di bawah untuk meneruskan.
@component('mail::panel')
<div style="text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 6px;">
{{ $otp }}
</div>
@endcomponent
Kod ini akan tamat tempoh dalam **{{ $minutes }} minit**.
Jika anda tidak membuat permintaan ini, abaikan e-mel ini.
@include('emails.partials.footer')
@endcomponent
@@ -1,9 +1,5 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
@include('emails.partials.header')
# Pengesahan E-mel
@@ -21,6 +17,5 @@ Kod ini akan tamat tempoh dalam **{{ $minutes }} minit**.
Jika anda tidak membuat pendaftaran ini, abaikan e-mel ini.
Terima kasih,<br>
{{ config('app.name') }}
@include('emails.partials.footer')
@endcomponent