Feature/phone register (#11)
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local> Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -10,23 +10,25 @@ use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Auth\Services\PhoneVerificationOtpService;
|
||||
use Modules\Role\Entities\Role;
|
||||
use Modules\User\Notifications\UserActivationNotification;
|
||||
use Modules\Auth\Services\EmailVerificationOtpService;
|
||||
use Modules\User\Policies\UserPolicy;
|
||||
use Exception;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, NotifiesAdmins;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function __construct(
|
||||
protected PhoneVerificationOtpService $phoneVerificationOtpService,
|
||||
) {}
|
||||
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
$phoneNumber = $this->phoneVerificationOtpService->normalizePhoneNumber($input['phone_number'] ?? '');
|
||||
|
||||
Validator::make(array_merge($input, ['phone_number' => $phoneNumber]), [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
@@ -36,15 +38,32 @@ class CreateNewUser implements CreatesNewUsers
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'ic_number' => ['required', 'string', 'max:255'],
|
||||
'phone_number' => [
|
||||
...$this->phoneVerificationOtpService->phoneNumberRules(),
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'phone_verification_token' => ['required', 'string', 'size:64'],
|
||||
'password' => ['required', 'string', 'min:8'],
|
||||
], [
|
||||
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||
'phone_number.unique' => 'Nombor telefon ini telah didaftarkan.',
|
||||
'phone_verification_token.required' => 'Pengesahan nombor telefon diperlukan.',
|
||||
])->validate();
|
||||
|
||||
$this->phoneVerificationOtpService->consumeRegistrationToken(
|
||||
$phoneNumber,
|
||||
$input['phone_verification_token'],
|
||||
);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'uuid' => Str::uuid(),
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'ic_number' => $input['ic_number'],
|
||||
'phone_number' => $phoneNumber,
|
||||
'phone_verified_at' => now(),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
@@ -54,9 +73,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);
|
||||
}
|
||||
@@ -65,21 +81,19 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify admins about new user requiring activation
|
||||
* Notify users who can kemaskini pengguna 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);
|
||||
$recipients = $this->getUsersWithPermission(UserPolicy::PERMISSION_UPDATE)
|
||||
->where('id', '!=', $newUser->id);
|
||||
|
||||
$sender = auth()->user() ?? $newUser; // Use current user as sender, or new user if no auth
|
||||
$sender = auth()->user() ?? $newUser;
|
||||
|
||||
foreach ($adminUsers as $admin) {
|
||||
foreach ($recipients as $recipient) {
|
||||
try {
|
||||
$admin->notify(new UserActivationNotification($newUser, $sender));
|
||||
$recipient->notify(new UserActivationNotification($newUser, $sender));
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to send user activation notification: '.$e->getMessage());
|
||||
}
|
||||
@@ -88,4 +102,5 @@ class CreateNewUser implements CreatesNewUsers
|
||||
Log::error('Failed to notify admins for user activation: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Auth\Contracts;
|
||||
|
||||
interface SmsSender
|
||||
{
|
||||
public function send(string $phoneNumber, string $message): void;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
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(
|
||||
protected string $otp
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$minutes = (int) config('auth.email_verification.expiry_minutes', 10);
|
||||
|
||||
return $this->mailMessage(
|
||||
subject: 'Pengesahan E-mel - Kod OTP',
|
||||
view: 'auth::emails.verification-otp',
|
||||
data: [
|
||||
'name' => $notifiable->name,
|
||||
'otp' => $this->otp,
|
||||
'minutes' => $minutes,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+15
-10
@@ -4,19 +4,21 @@ namespace Modules\Auth\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EmailVerificationOtp extends Model
|
||||
class PhoneVerificationOtp extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'email_verification_otps';
|
||||
protected $table = 'phone_verification_otps';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'phone_number',
|
||||
'code',
|
||||
'expires_at',
|
||||
'attempts',
|
||||
'verified_at',
|
||||
'verification_token',
|
||||
'verification_token_expires_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -24,14 +26,11 @@ class EmailVerificationOtp extends Model
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'attempts' => 'integer',
|
||||
'verified_at' => 'datetime',
|
||||
'verification_token_expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
@@ -39,6 +38,12 @@ class EmailVerificationOtp extends Model
|
||||
|
||||
public function hasExceededMaxAttempts(): bool
|
||||
{
|
||||
return $this->attempts >= (int) config('auth.email_verification.max_attempts', 5);
|
||||
return $this->attempts >= (int) config('auth.phone_verification.max_attempts', 5);
|
||||
}
|
||||
|
||||
public function isVerificationTokenExpired(): bool
|
||||
{
|
||||
return $this->verification_token_expires_at === null
|
||||
|| $this->verification_token_expires_at->isPast();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -43,6 +45,7 @@ class User extends Authenticatable
|
||||
'ic_number',
|
||||
'position',
|
||||
'phone_number',
|
||||
'phone_verified_at',
|
||||
'image_url',
|
||||
'status',
|
||||
'two_factor_secret',
|
||||
@@ -129,6 +132,7 @@ class User extends Authenticatable
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'phone_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'status' => 'string',
|
||||
'gender' => 'string',
|
||||
@@ -301,9 +305,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 +319,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Auth\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Auth\Services\PhoneVerificationOtpService;
|
||||
use Modules\Auth\Transformers\UserResource;
|
||||
|
||||
class PhoneVerificationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PhoneVerificationOtpService $phoneVerificationOtpService,
|
||||
) {}
|
||||
|
||||
public function send(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
|
||||
], [
|
||||
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||
]);
|
||||
|
||||
$this->phoneVerificationOtpService->send($validated['phone_number']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Kod OTP telah dihantar ke nombor telefon anda.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verify(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
|
||||
'otp' => ['required', 'string', 'digits:6'],
|
||||
], [
|
||||
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||
'otp.required' => 'Kod OTP diperlukan.',
|
||||
'otp.digits' => 'Kod OTP mestilah 6 digit.',
|
||||
]);
|
||||
|
||||
$result = $this->phoneVerificationOtpService->verify(
|
||||
$validated['phone_number'],
|
||||
$validated['otp'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Nombor telefon berjaya disahkan.',
|
||||
'data' => [
|
||||
'phone_number' => $result['phone_number'],
|
||||
'verification_token' => $result['verification_token'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendForAuthenticatedUser(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
|
||||
], [
|
||||
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||
]);
|
||||
|
||||
$this->phoneVerificationOtpService->sendForUser($user, $validated['phone_number']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Kod OTP telah dihantar ke nombor telefon anda.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyForAuthenticatedUser(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$wasVerified = (bool) $user->phone_verified_at;
|
||||
|
||||
$validated = $request->validate([
|
||||
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
|
||||
'otp' => ['required', 'string', 'digits:6'],
|
||||
], [
|
||||
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||
'otp.required' => 'Kod OTP diperlukan.',
|
||||
'otp.digits' => 'Kod OTP mestilah 6 digit.',
|
||||
]);
|
||||
|
||||
$verifiedUser = $this->phoneVerificationOtpService->verifyForUser(
|
||||
$user,
|
||||
$validated['phone_number'],
|
||||
$validated['otp'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $wasVerified
|
||||
? 'Nombor telefon berjaya dikemas kini.'
|
||||
: 'Nombor telefon berjaya disahkan.',
|
||||
'data' => new UserResource($verifiedUser),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Auth\Notifications;
|
||||
|
||||
use App\Notifications\Concerns\BuildsMailMessage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class VerifyEmailNotification extends Notification
|
||||
{
|
||||
use BuildsMailMessage;
|
||||
use Queueable;
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$expireMinutes = (int) config('auth.verification.expire_minutes', 60);
|
||||
|
||||
return $this->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()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ use Modules\Auth\Actions\Fortify\CreateNewUser;
|
||||
use Modules\Auth\Actions\Fortify\ResetUserPassword;
|
||||
use Modules\Auth\Actions\Fortify\UpdateUserPassword;
|
||||
use Modules\Auth\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use Modules\Auth\Contracts\SmsSender;
|
||||
use Modules\Auth\Services\LogSmsSender;
|
||||
use Modules\Auth\Services\OneWaySmsSender;
|
||||
use Nwidart\Modules\Traits\PathNamespace;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
@@ -42,6 +45,13 @@ class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->app->register(EventServiceProvider::class);
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
|
||||
$this->app->bind(SmsSender::class, function () {
|
||||
return match (config('onewaysms.driver')) {
|
||||
'log' => $this->app->make(LogSmsSender::class),
|
||||
default => $this->app->make(OneWaySmsSender::class),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Auth\Http\Controllers\EmailVerificationController;
|
||||
use Modules\Auth\Http\Controllers\PasswordResetController;
|
||||
use Modules\Auth\Http\Controllers\PhoneVerificationController;
|
||||
use Modules\Auth\Http\Controllers\SessionController;
|
||||
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
|
||||
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
|
||||
|
||||
// Auth
|
||||
Route::post('/phone-verification/send', [PhoneVerificationController::class, 'send'])
|
||||
->middleware('throttle:phone-verification-send');
|
||||
Route::post('/phone-verification/verify', [PhoneVerificationController::class, 'verify'])
|
||||
->middleware('throttle:phone-verification-verify');
|
||||
|
||||
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 +35,14 @@ 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');
|
||||
|
||||
// optional phone verification (post-login)
|
||||
Route::post('/phone-verification/send', [PhoneVerificationController::class, 'sendForAuthenticatedUser'])
|
||||
->middleware('throttle:phone-verification-send');
|
||||
Route::post('/phone-verification/verify', [PhoneVerificationController::class, 'verifyForAuthenticatedUser'])
|
||||
->middleware('throttle:phone-verification-verify');
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Auth\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Auth\Entities\EmailVerificationOtp;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Auth\Emails\EmailVerificationOtpEmail;
|
||||
|
||||
class EmailVerificationOtpService
|
||||
{
|
||||
public function send(User $user): void
|
||||
{
|
||||
if ($user->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Auth\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Auth\Contracts\SmsSender;
|
||||
|
||||
class LogSmsSender implements SmsSender
|
||||
{
|
||||
public function send(string $phoneNumber, string $message): void
|
||||
{
|
||||
Log::info('SMS sent (log driver)', [
|
||||
'phone_number' => $phoneNumber,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Auth\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Auth\Contracts\SmsSender;
|
||||
use RuntimeException;
|
||||
|
||||
class OneWaySmsSender implements SmsSender
|
||||
{
|
||||
public function send(string $phoneNumber, string $message): void
|
||||
{
|
||||
$baseUrl = (string) config('onewaysms.base_url');
|
||||
$username = (string) config('onewaysms.api_username');
|
||||
$password = (string) config('onewaysms.api_password');
|
||||
$senderId = (string) config('onewaysms.sender_id');
|
||||
|
||||
if ($baseUrl === '' || $username === '' || $password === '' || $senderId === '') {
|
||||
throw new RuntimeException('OneWaySMS credentials are not configured.');
|
||||
}
|
||||
|
||||
$mobileNo = $this->toInternationalMobileNumber($phoneNumber);
|
||||
|
||||
$response = Http::timeout((int) config('http.timeout', 30))
|
||||
->connectTimeout((int) config('http.connect_timeout', 10))
|
||||
->get($baseUrl, [
|
||||
'apiusername' => $username,
|
||||
'apipassword' => $password,
|
||||
'senderid' => $senderId,
|
||||
'mobileno' => $mobileNo,
|
||||
'message' => $message,
|
||||
'languagetype' => 1,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('OneWaySMS HTTP request failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
'phone_number' => $mobileNo,
|
||||
]);
|
||||
|
||||
throw new RuntimeException('Failed to send SMS via OneWaySMS.');
|
||||
}
|
||||
|
||||
$mtId = trim($response->body());
|
||||
|
||||
// Positive MT ID = success; zero/negative = gateway error codes.
|
||||
if (! is_numeric($mtId) || (int) $mtId <= 0) {
|
||||
Log::error('OneWaySMS gateway rejected SMS', [
|
||||
'mt_id' => $mtId,
|
||||
'phone_number' => $mobileNo,
|
||||
]);
|
||||
|
||||
throw new RuntimeException('OneWaySMS gateway rejected the SMS request.');
|
||||
}
|
||||
|
||||
Log::info('OneWaySMS sent successfully', [
|
||||
'mt_id' => $mtId,
|
||||
'phone_number' => $mobileNo,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function toInternationalMobileNumber(string $phoneNumber): string
|
||||
{
|
||||
$phoneNumber = preg_replace('/[\s\-]/', '', trim($phoneNumber)) ?? '';
|
||||
|
||||
if (str_starts_with($phoneNumber, '+')) {
|
||||
$phoneNumber = substr($phoneNumber, 1);
|
||||
}
|
||||
|
||||
if (str_starts_with($phoneNumber, '0')) {
|
||||
$phoneNumber = '60'.substr($phoneNumber, 1);
|
||||
}
|
||||
|
||||
return $phoneNumber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Auth\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Auth\Contracts\SmsSender;
|
||||
use Modules\Auth\Entities\PhoneVerificationOtp;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class PhoneVerificationOtpService
|
||||
{
|
||||
public function __construct(
|
||||
protected SmsSender $smsSender,
|
||||
) {}
|
||||
|
||||
public function send(string $phoneNumber): void
|
||||
{
|
||||
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||
|
||||
if ($this->phoneNumberBelongsToAnotherUser($phoneNumber)) {
|
||||
throw ValidationException::withMessages([
|
||||
'phone_number' => ['Nombor telefon ini telah didaftarkan.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->dispatchOtp($phoneNumber);
|
||||
}
|
||||
|
||||
public function sendForUser(User $user, string $phoneNumber): void
|
||||
{
|
||||
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||
|
||||
$this->assertPhoneNumberAvailableForUser($user, $phoneNumber);
|
||||
|
||||
if ($user->phone_verified_at && $user->phone_number === $phoneNumber) {
|
||||
throw ValidationException::withMessages([
|
||||
'phone_number' => ['Nombor telefon ini sama dengan nombor sedia ada.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->dispatchOtp($phoneNumber);
|
||||
}
|
||||
|
||||
public function verifyForUser(User $user, string $phoneNumber, string $otp): User
|
||||
{
|
||||
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||
|
||||
$this->assertPhoneNumberAvailableForUser($user, $phoneNumber);
|
||||
|
||||
$record = $this->validateOtpRecord($phoneNumber, $otp);
|
||||
$record->delete();
|
||||
|
||||
$user->forceFill([
|
||||
'phone_number' => $phoneNumber,
|
||||
'phone_verified_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{verification_token: string, phone_number: string}
|
||||
*/
|
||||
public function verify(string $phoneNumber, string $otp): array
|
||||
{
|
||||
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||
|
||||
$record = $this->validateOtpRecord($phoneNumber, $otp);
|
||||
|
||||
$verificationToken = Str::random(64);
|
||||
|
||||
$record->forceFill([
|
||||
'verified_at' => now(),
|
||||
'verification_token' => Hash::make($verificationToken),
|
||||
'verification_token_expires_at' => now()->addMinutes($this->tokenExpiryMinutes()),
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'verification_token' => $verificationToken,
|
||||
'phone_number' => $phoneNumber,
|
||||
];
|
||||
}
|
||||
|
||||
public function consumeRegistrationToken(string $phoneNumber, string $verificationToken): void
|
||||
{
|
||||
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||
|
||||
$record = PhoneVerificationOtp::query()
|
||||
->where('phone_number', $phoneNumber)
|
||||
->whereNotNull('verified_at')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $record || $record->isVerificationTokenExpired()) {
|
||||
throw ValidationException::withMessages([
|
||||
'phone_verification_token' => ['Pengesahan nombor telefon tidak sah atau telah tamat tempoh. Sila sahkan semula.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! Hash::check($verificationToken, (string) $record->verification_token)) {
|
||||
throw ValidationException::withMessages([
|
||||
'phone_verification_token' => ['Pengesahan nombor telefon tidak sah atau telah tamat tempoh. Sila sahkan semula.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
}
|
||||
|
||||
protected function dispatchOtp(string $phoneNumber): void
|
||||
{
|
||||
$otp = $this->generateOtp();
|
||||
|
||||
PhoneVerificationOtp::query()
|
||||
->where('phone_number', $phoneNumber)
|
||||
->delete();
|
||||
|
||||
PhoneVerificationOtp::create([
|
||||
'phone_number' => $phoneNumber,
|
||||
'code' => Hash::make($otp),
|
||||
'expires_at' => now()->addMinutes($this->otpExpiryMinutes()),
|
||||
'attempts' => 0,
|
||||
]);
|
||||
|
||||
$this->smsSender->send(
|
||||
$phoneNumber,
|
||||
$this->buildOtpMessage($otp),
|
||||
);
|
||||
}
|
||||
|
||||
protected function validateOtpRecord(string $phoneNumber, string $otp): PhoneVerificationOtp
|
||||
{
|
||||
$record = PhoneVerificationOtp::query()
|
||||
->where('phone_number', $phoneNumber)
|
||||
->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.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
protected function assertPhoneNumberAvailableForUser(User $user, string $phoneNumber): void
|
||||
{
|
||||
if ($this->phoneNumberBelongsToAnotherUser($phoneNumber, $user->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'phone_number' => ['Nombor telefon ini telah digunakan oleh akaun lain.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function phoneNumberBelongsToAnotherUser(string $phoneNumber, ?string $exceptUserId = null): bool
|
||||
{
|
||||
return User::query()
|
||||
->where('phone_number', $phoneNumber)
|
||||
->when($exceptUserId, fn ($query) => $query->where('id', '!=', $exceptUserId))
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function normalizePhoneNumber(string $phoneNumber): string
|
||||
{
|
||||
$phoneNumber = preg_replace('/[\s\-]/', '', trim($phoneNumber)) ?? '';
|
||||
|
||||
if (str_starts_with($phoneNumber, '+60')) {
|
||||
$phoneNumber = '0'.substr($phoneNumber, 3);
|
||||
} elseif (str_starts_with($phoneNumber, '60') && strlen($phoneNumber) > 10) {
|
||||
$phoneNumber = '0'.substr($phoneNumber, 2);
|
||||
}
|
||||
|
||||
return $phoneNumber;
|
||||
}
|
||||
|
||||
public function phoneNumberRules(): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'max:20',
|
||||
'regex:/^(\+?60|0)1[0-9]{8,9}$/',
|
||||
];
|
||||
}
|
||||
|
||||
protected function generateOtp(): string
|
||||
{
|
||||
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
protected function buildOtpMessage(string $otp): string
|
||||
{
|
||||
$template = (string) config('auth.phone_verification.message');
|
||||
|
||||
return str_replace(
|
||||
[':otp', ':minutes'],
|
||||
[$otp, (string) $this->otpExpiryMinutes()],
|
||||
$template,
|
||||
);
|
||||
}
|
||||
|
||||
protected function otpExpiryMinutes(): int
|
||||
{
|
||||
return (int) config('auth.phone_verification.otp_expiry_minutes', 10);
|
||||
}
|
||||
|
||||
protected function tokenExpiryMinutes(): int
|
||||
{
|
||||
return (int) config('auth.phone_verification.token_expiry_minutes', 30);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Auth\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Position\Transformers\PositionResource;
|
||||
use Modules\Rank\Transformers\RankResource;
|
||||
use Modules\Unit\Transformers\UnitResource;
|
||||
|
||||
class SSOUserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'army_number' => $this->army_number,
|
||||
'unit_id' => $this->unit_id,
|
||||
'rank_id' => $this->rank_id,
|
||||
'position_id' => $this->position_id,
|
||||
'phone_number' => $this->phone_number,
|
||||
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
|
||||
'status' => $this->status,
|
||||
'token' => $this->when(isset($this->token), $this->token),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
'roles' => $this->whenLoaded('roles', function () {
|
||||
return $this->roles->map(function ($role) {
|
||||
return [
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'guard_name' => $role->guard_name,
|
||||
'permissions' => $role->permissions ? $role->permissions->map(function ($permission) {
|
||||
return [
|
||||
'id' => $permission->id,
|
||||
'name' => $permission->name,
|
||||
'guard_name' => $permission->guard_name,
|
||||
'route_name' => $permission->route_name ?? null,
|
||||
'created_at' => $permission->created_at,
|
||||
'updated_at' => $permission->updated_at,
|
||||
];
|
||||
}) : [],
|
||||
'created_at' => $role->created_at,
|
||||
'updated_at' => $role->updated_at,
|
||||
];
|
||||
});
|
||||
}),
|
||||
'unit' => new UnitResource($this->whenLoaded('unit')),
|
||||
'rank' => new RankResource($this->whenLoaded('rank')),
|
||||
'position' => new PositionResource($this->whenLoaded('position')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class UserResource extends JsonResource
|
||||
'ic_number' => $this->ic_number,
|
||||
'position' => $this->position,
|
||||
'phone_number' => $this->phone_number,
|
||||
'phone_verified_at' => $this->phone_verified_at,
|
||||
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
|
||||
'status' => $this->status,
|
||||
'two_factor_secret' => $this->two_factor_secret,
|
||||
|
||||
@@ -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')
|
||||
<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 pendaftaran ini, abaikan e-mel ini.
|
||||
|
||||
@include('emails.partials.footer')
|
||||
@endcomponent
|
||||
@@ -0,0 +1,19 @@
|
||||
@component('mail::message')
|
||||
@include('emails.partials.header')
|
||||
|
||||
# Pengesahan E-mel
|
||||
|
||||
Assalamualaikum **{{ $name }}**,
|
||||
|
||||
Terima kasih kerana mendaftar dengan MyKOPKB. Sila sahkan alamat e-mel anda dengan mengklik butang di bawah.
|
||||
|
||||
@component('mail::button', ['url' => $verificationUrl])
|
||||
Sahkan E-mel
|
||||
@endcomponent
|
||||
|
||||
Pautan ini akan tamat tempoh dalam **{{ $expireMinutes }} minit**.
|
||||
|
||||
Jika anda tidak membuat permintaan ini, abaikan e-mel ini.
|
||||
|
||||
@include('emails.partials.footer')
|
||||
@endcomponent
|
||||
Reference in New Issue
Block a user