DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user