DONE: replace email verification by link instead of otp
This commit is contained in:
@@ -5,15 +5,15 @@
|
|||||||
## Improvement
|
## Improvement
|
||||||
[ ] mesyuarat anggota tertinggi
|
[ ] mesyuarat anggota tertinggi
|
||||||
[ ] penyata anggota
|
[ ] penyata anggota
|
||||||
[ ] daftar anggota
|
[x] daftar anggota
|
||||||
[ ] baki pinjaman - transaction history: tarik dari ubs
|
[ ] baki pinjaman - transaction history: tarik dari ubs
|
||||||
[ ] pembiayaan anggota
|
[ ] pembiayaan anggota
|
||||||
[ ] sumbangan
|
[ ] sumbangan
|
||||||
[ ] wasi/penama
|
[x] wasi/penama
|
||||||
[ ] pendaftaran anggota/meneruskan anggota/pencen
|
[ ] pendaftaran anggota/meneruskan anggota/pencen
|
||||||
[ ] daftar lembaga (backdated)
|
[ ] daftar lembaga (backdated)
|
||||||
[ ] boleh print semua borang
|
[x] boleh print semua borang
|
||||||
[ ] jana surat lepas lulus anggota
|
[x] jana surat lepas lulus anggota
|
||||||
|
|
||||||
## Present to Boss (2/7/2026)
|
## Present to Boss (2/7/2026)
|
||||||
[x] discuss logo baru MyKOPKB
|
[x] discuss logo baru MyKOPKB
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ APP_ENV=local
|
|||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@@ -87,3 +88,6 @@ AUTH_COOKIE_EXPOSE_TOKEN=false
|
|||||||
ACTIVE_ROLE_PREFER_MEMBER=true
|
ACTIVE_ROLE_PREFER_MEMBER=true
|
||||||
ACTIVE_ROLE_MEMBER_REDIRECT=/profile
|
ACTIVE_ROLE_MEMBER_REDIRECT=/profile
|
||||||
ACTIVE_ROLE_ADMIN_REDIRECT=/profile
|
ACTIVE_ROLE_ADMIN_REDIRECT=/profile
|
||||||
|
|
||||||
|
EMAIL_VERIFICATION_EXPIRE_MINUTES=60
|
||||||
|
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use Laravel\Fortify\Contracts\CreatesNewUsers;
|
|||||||
use Modules\Auth\Entities\User;
|
use Modules\Auth\Entities\User;
|
||||||
use Modules\Role\Entities\Role;
|
use Modules\Role\Entities\Role;
|
||||||
use Modules\User\Notifications\UserActivationNotification;
|
use Modules\User\Notifications\UserActivationNotification;
|
||||||
use Modules\Auth\Services\EmailVerificationOtpService;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
@@ -54,38 +53,6 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
$user->assignRole($role);
|
$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;
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,39 +3,19 @@
|
|||||||
namespace Modules\Auth\Actions\Fortify;
|
namespace Modules\Auth\Actions\Fortify;
|
||||||
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Laravel\Fortify\Contracts\LoginResponse as ContractsLoginResponse;
|
use Laravel\Fortify\Contracts\LoginResponse as ContractsLoginResponse;
|
||||||
use Modules\Auth\Services\AuthSessionService;
|
use Modules\Auth\Services\AuthSessionService;
|
||||||
use Modules\Auth\Services\EmailVerificationOtpService;
|
|
||||||
|
|
||||||
class LoginResponse implements ContractsLoginResponse
|
class LoginResponse implements ContractsLoginResponse
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AuthSessionService $authSession,
|
protected AuthSessionService $authSession,
|
||||||
protected EmailVerificationOtpService $otpService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function toResponse($request): JsonResponse
|
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(
|
return $this->authSession->createAuthResponse(
|
||||||
$user,
|
$request->user(),
|
||||||
'Login successful'
|
'Login successful'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,21 @@
|
|||||||
namespace Modules\Auth\Actions\Fortify;
|
namespace Modules\Auth\Actions\Fortify;
|
||||||
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||||
|
use Modules\Auth\Services\AuthSessionService;
|
||||||
|
|
||||||
class RegisterResponse implements RegisterResponseContract
|
class RegisterResponse implements RegisterResponseContract
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected AuthSessionService $authSession,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function toResponse($request): JsonResponse
|
public function toResponse($request): JsonResponse
|
||||||
{
|
{
|
||||||
$email = $request->user()?->email;
|
return $this->authSession->createAuthResponse(
|
||||||
|
$request->user(),
|
||||||
Auth::guard(config('fortify.guard'))->logout();
|
'Pendaftaran berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.',
|
||||||
|
201
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
use HasUuids;
|
|
||||||
|
|
||||||
protected $table = 'email_verification_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.email_verification.max_attempts', 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
namespace Modules\Auth\Entities;
|
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\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
@@ -22,12 +23,13 @@ use Modules\Role\Entities\Role;
|
|||||||
use Modules\User\Entities\Address;
|
use Modules\User\Entities\Address;
|
||||||
use Modules\User\Entities\BankDetail;
|
use Modules\User\Entities\BankDetail;
|
||||||
use Modules\User\Entities\Employment;
|
use Modules\User\Entities\Employment;
|
||||||
|
use Modules\Auth\Notifications\VerifyEmailNotification;
|
||||||
use Modules\User\Entities\Heir;
|
use Modules\User\Entities\Heir;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Modules\Auth\Database\Factories\UserFactory> */
|
/** @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';
|
protected $table = 'users';
|
||||||
|
|
||||||
@@ -301,9 +303,9 @@ class User extends Authenticatable
|
|||||||
return $currentUser->hasPermissionTo('menyamar pengguna');
|
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
|
public function canAuthenticate(): bool
|
||||||
{
|
{
|
||||||
if ($this->canLogin()) {
|
return in_array($this->status, ['active', 'pending'], true);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->hasVerifiedEmail() && $this->status === 'pending';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,60 +3,66 @@
|
|||||||
namespace Modules\Auth\Http\Controllers;
|
namespace Modules\Auth\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Modules\Auth\Entities\User;
|
use Modules\Auth\Entities\User;
|
||||||
use Modules\Auth\Services\AuthSessionService;
|
|
||||||
use Modules\Auth\Services\EmailVerificationOtpService;
|
|
||||||
|
|
||||||
class EmailVerificationController extends Controller
|
class EmailVerificationController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function verify(Request $request, string $id, string $hash): RedirectResponse
|
||||||
protected EmailVerificationOtpService $otpService,
|
|
||||||
protected AuthSessionService $authSession
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function verify(Request $request): JsonResponse
|
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
if (! URL::hasValidSignature($request)) {
|
||||||
'email' => ['required', 'string', 'email'],
|
return $this->redirectToFrontend('invalid');
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$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(
|
if ($user->hasVerifiedEmail()) {
|
||||||
$user,
|
return $this->redirectToFrontend('already');
|
||||||
'E-mel anda telah berjaya disahkan. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.'
|
}
|
||||||
);
|
|
||||||
|
$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([
|
$user = $request->user();
|
||||||
'email' => ['required', 'string', 'email'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::where('email', $validated['email'])->first();
|
if ($user->hasVerifiedEmail()) {
|
||||||
|
return response()->json([
|
||||||
if ($user && ! $user->hasVerifiedEmail()) {
|
'success' => true,
|
||||||
$this->otpService->send($user);
|
'message' => 'E-mel anda telah disahkan.',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'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,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()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,9 @@ use Laravel\Fortify\Http\Controllers\RegisteredUserController;
|
|||||||
Route::post('/register', [RegisteredUserController::class, 'store']);
|
Route::post('/register', [RegisteredUserController::class, 'store']);
|
||||||
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools');
|
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools');
|
||||||
|
|
||||||
Route::post('/verify-email', [EmailVerificationController::class, 'verify'])
|
Route::get('/email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])
|
||||||
->middleware('throttle:email-verification');
|
->middleware('throttle:email-verification')
|
||||||
Route::post('/verify-email/resend', [EmailVerificationController::class, 'resend'])
|
->name('verification.verify');
|
||||||
->middleware('throttle:email-verification-resend');
|
|
||||||
|
|
||||||
Route::post('/forgot-password', [PasswordResetController::class, 'requestOtp'])
|
Route::post('/forgot-password', [PasswordResetController::class, 'requestOtp'])
|
||||||
->middleware('throttle:password-reset-request');
|
->middleware('throttle:password-reset-request');
|
||||||
@@ -30,4 +29,8 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
|
|||||||
|
|
||||||
// logout
|
// logout
|
||||||
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']);
|
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']);
|
||||||
|
|
||||||
|
// optional email verification (post-login)
|
||||||
|
Route::post('/email/verification-notification', [EmailVerificationController::class, 'send'])
|
||||||
|
->middleware('throttle:email-verification-resend');
|
||||||
});
|
});
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -14,7 +14,6 @@ use Illuminate\Support\Facades\Notification;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Modules\Auth\Entities\User;
|
use Modules\Auth\Entities\User;
|
||||||
use Modules\Auth\Services\EmailVerificationOtpService;
|
|
||||||
use Modules\MembershipApplication\Entities\MembershipApplication;
|
use Modules\MembershipApplication\Entities\MembershipApplication;
|
||||||
use Modules\MembershipApplication\Enums\ApplicationStatus;
|
use Modules\MembershipApplication\Enums\ApplicationStatus;
|
||||||
use Modules\MembershipApplication\Enums\BoardDecision;
|
use Modules\MembershipApplication\Enums\BoardDecision;
|
||||||
@@ -513,8 +512,6 @@ class MembershipApplicationService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
app(EmailVerificationOtpService::class)->send($user);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'plainPassword' => $plainPassword,
|
'plainPassword' => $plainPassword,
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
RateLimiter::for('email-verification', function (Request $request) {
|
RateLimiter::for('email-verification', function (Request $request) {
|
||||||
$throttleKey = Str::transliterate(Str::lower($request->input('email', '')).'|'.$request->ip());
|
$throttleKey = Str::transliterate($request->route('id', '').'|'.$request->ip());
|
||||||
|
|
||||||
return Limit::perMinute(5)->by($throttleKey);
|
return Limit::perMinute(6)->by($throttleKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
RateLimiter::for('email-verification-resend', function (Request $request) {
|
RateLimiter::for('email-verification-resend', function (Request $request) {
|
||||||
@@ -87,10 +87,6 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
$shouldBypassPassword = config('app.env', 'local');
|
$shouldBypassPassword = config('app.env', 'local');
|
||||||
|
|
||||||
if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) {
|
if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) {
|
||||||
if (! $user->hasVerifiedEmail()) {
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAuthenticate()) {
|
if (! $user->canAuthenticate()) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => [$user->getLoginRestrictionMessage()],
|
'email' => [$user->getLoginRestrictionMessage()],
|
||||||
|
|||||||
+4
-4
@@ -113,13 +113,13 @@ return [
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Email Verification OTP
|
| Email Verification Link
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'email_verification' => [
|
'verification' => [
|
||||||
'expiry_minutes' => (int) env('EMAIL_VERIFICATION_OTP_EXPIRY_MINUTES', 10),
|
'expire_minutes' => (int) env('EMAIL_VERIFICATION_EXPIRE_MINUTES', 60),
|
||||||
'max_attempts' => (int) env('EMAIL_VERIFICATION_OTP_MAX_ATTEMPTS', 5),
|
'frontend_redirect_path' => env('EMAIL_VERIFICATION_REDIRECT_PATH', '/profile'),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
+5
-13
@@ -6,10 +6,12 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('email_verification_otps');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::create('email_verification_otps', function (Blueprint $table) {
|
Schema::create('email_verification_otps', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->uuid('id')->primary();
|
||||||
@@ -18,16 +20,6 @@ return new class extends Migration
|
|||||||
$table->timestamp('expires_at');
|
$table->timestamp('expires_at');
|
||||||
$table->unsignedTinyInteger('attempts')->default(0);
|
$table->unsignedTinyInteger('attempts')->default(0);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->index(['user_id', 'expires_at']);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('email_verification_otps');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { sendVerificationEmail } from '@/modules/auth'
|
||||||
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
|
|
||||||
|
const dismissed = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const feedbackMessage = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
loading.value = true
|
||||||
|
feedbackMessage.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendVerificationEmail()
|
||||||
|
feedbackMessage.value = response.message
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getApiErrorMessage(error, 'Gagal menghantar pautan pengesahan. Sila cuba lagi.')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertRoot v-if="!dismissed" class="mb-4" variant="primary">
|
||||||
|
<AlertTitle>Pengesahan emel diperlukan.</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Sila sahkan emel anda dengan menekan butang di bawah.
|
||||||
|
<span v-if="feedbackMessage" class="mt-2 block">{{ feedbackMessage }}</span>
|
||||||
|
<span v-if="errorMessage" class="mt-2 block text-danger">{{ errorMessage }}</span>
|
||||||
|
</AlertDescription>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="primary" look="outline" type="button" :disabled="loading" @click="handleSend">
|
||||||
|
{{ loading ? 'Menghantar...' : 'Hantar Pautan Pengesahan' }}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" type="button" @click="dismissed = true">
|
||||||
|
Abaikan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertRoot>
|
||||||
|
</template>
|
||||||
@@ -4,28 +4,25 @@ export {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
register,
|
register,
|
||||||
verifyEmail,
|
sendVerificationEmail,
|
||||||
resendVerificationEmail,
|
|
||||||
requestForgotPassword,
|
requestForgotPassword,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
getAuthErrorMessage,
|
getAuthErrorMessage,
|
||||||
getRegisterErrorMessage,
|
getRegisterErrorMessage,
|
||||||
getVerifyEmailErrorMessage,
|
|
||||||
getForgotPasswordErrorMessage,
|
getForgotPasswordErrorMessage,
|
||||||
getResetPasswordErrorMessage,
|
getResetPasswordErrorMessage,
|
||||||
resolvePostLoginRoute,
|
resolvePostLoginRoute,
|
||||||
resolvePostAuthRoute,
|
resolvePostAuthRoute,
|
||||||
isAccountPending,
|
isAccountPending,
|
||||||
isLoginVerificationRequired,
|
isEmailVerified,
|
||||||
} from './services/auth.service'
|
} from './services/auth.service'
|
||||||
export type {
|
export type {
|
||||||
LoginCredentials,
|
LoginCredentials,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
RegisterCredentials,
|
RegisterCredentials,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
VerifyEmailPayload,
|
ResendVerificationResponse,
|
||||||
VerifyEmailResponse,
|
|
||||||
ForgotPasswordPayload,
|
ForgotPasswordPayload,
|
||||||
ForgotPasswordResponse,
|
ForgotPasswordResponse,
|
||||||
ResetPasswordPayload,
|
ResetPasswordPayload,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Box } from '@/components/ui/box'
|
import { Box } from '@/components/ui/box'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
@@ -10,6 +10,7 @@ import logoUrl from '@/assets/images/logo.svg'
|
|||||||
import illustrationUrl from '@/assets/images/logo.svg'
|
import illustrationUrl from '@/assets/images/logo.svg'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const loggingOut = ref(false)
|
const loggingOut = ref(false)
|
||||||
@@ -44,6 +45,12 @@ const handleLogout = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
const verified = route.query.verified
|
||||||
|
if (typeof verified === 'string' && verified) {
|
||||||
|
authStore.fetchSession()
|
||||||
|
router.replace({ query: { ...route.query, verified: undefined } })
|
||||||
|
}
|
||||||
|
|
||||||
statusPollInterval = setInterval(checkActivationStatus, 30000)
|
statusPollInterval = setInterval(checkActivationStatus, 30000)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,21 +99,21 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<AlertRoot class="mt-8" variant="primary">
|
<AlertRoot class="mt-8" variant="primary">
|
||||||
<AlertTitle>E-mel disahkan</AlertTitle>
|
<AlertTitle>Menunggu pengaktifan</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem. Anda akan dapat
|
Pendaftaran anda berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir
|
||||||
mengakses sistem selepas akaun diaktifkan.
|
sistem. Anda akan dapat mengakses sistem selepas akaun diaktifkan.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</AlertRoot>
|
</AlertRoot>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-col gap-4">
|
<div class="mt-8 flex flex-col gap-4">
|
||||||
<Button class="box w-full px-4 py-5" variant="primary" type="button" :disabled="checkingStatus"
|
<Button class="box w-full px-4 py-5" variant="primary" type="button" :disabled="checkingStatus"
|
||||||
@click="checkActivationStatus">
|
@click="checkActivationStatus">
|
||||||
{{ checkingStatus ? 'Checking...' : 'Semak Status' }}
|
{{ checkingStatus ? 'Menyemak...' : 'Semak Status' }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="box w-full px-4 py-5" look="outline" type="button" :disabled="loggingOut"
|
<Button class="box w-full px-4 py-5" look="outline" type="button" :disabled="loggingOut"
|
||||||
@click="handleLogout">
|
@click="handleLogout">
|
||||||
{{ loggingOut ? 'Logging out...' : 'Log Keluar' }}
|
{{ loggingOut ? 'Log keluar...' : 'Log Keluar' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -47,18 +47,12 @@ const handleLogin = async () => {
|
|||||||
remember: remember.value,
|
remember: remember.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const loginData = response.data
|
authStore.setSession(
|
||||||
|
response.data.user,
|
||||||
if ('requires_email_verification' in loginData) {
|
response.active_role ?? null,
|
||||||
await router.push({
|
response.can_switch_role ?? false,
|
||||||
name: 'verify-email',
|
)
|
||||||
query: { email: loginData.email },
|
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
authStore.setSession(loginData.user, response.active_role ?? null, response.can_switch_role ?? false)
|
|
||||||
await router.push(resolvePostAuthRoute(loginData.user, response.redirect_path))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = getAuthErrorMessage(error)
|
errorMessage.value = getAuthErrorMessage(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/ch
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
import { PasswordInput } from '@/components/ui/password-input'
|
import { PasswordInput } from '@/components/ui/password-input'
|
||||||
import { getRegisterErrorMessage, register } from '@/modules/auth'
|
import { getRegisterErrorMessage, register, resolvePostAuthRoute } from '@/modules/auth'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||||
import illustrationUrl from '@/assets/images/logo.svg'
|
import illustrationUrl from '@/assets/images/logo.svg'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
@@ -48,10 +50,12 @@ const handleRegister = async () => {
|
|||||||
password_confirmation: passwordConfirmation.value,
|
password_confirmation: passwordConfirmation.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
await router.push({
|
authStore.setSession(
|
||||||
name: 'verify-email',
|
response.data.user,
|
||||||
query: { email: response.data.email },
|
response.active_role ?? null,
|
||||||
})
|
response.can_switch_role ?? false,
|
||||||
|
)
|
||||||
|
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = getRegisterErrorMessage(error)
|
errorMessage.value = getRegisterErrorMessage(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { Box } from '@/components/ui/box'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
|
||||||
import {
|
|
||||||
getVerifyEmailErrorMessage,
|
|
||||||
resendVerificationEmail,
|
|
||||||
resolvePostAuthRoute,
|
|
||||||
verifyEmail,
|
|
||||||
} from '@/modules/auth'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import logoUrl from '@/assets/images/logo.svg'
|
|
||||||
import illustrationUrl from '@/assets/images/logo.svg'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const email = ref('')
|
|
||||||
const otp = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const resendLoading = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const resendMessage = ref('')
|
|
||||||
|
|
||||||
const canSubmit = computed(() => email.value.length > 0 && otp.value.length === 6)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const queryEmail = route.query.email
|
|
||||||
if (typeof queryEmail === 'string' && queryEmail) {
|
|
||||||
email.value = queryEmail
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleVerify = async () => {
|
|
||||||
errorMessage.value = ''
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await verifyEmail({
|
|
||||||
email: email.value,
|
|
||||||
otp: otp.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
authStore.setSession(response.data.user, response.active_role, response.can_switch_role)
|
|
||||||
await router.push(resolvePostAuthRoute(response.data.user))
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = getVerifyEmailErrorMessage(error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResend = async () => {
|
|
||||||
if (!email.value) {
|
|
||||||
errorMessage.value = 'Sila masukkan alamat e-mel.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
errorMessage.value = ''
|
|
||||||
resendMessage.value = ''
|
|
||||||
resendLoading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await resendVerificationEmail(email.value)
|
|
||||||
resendMessage.value = response.message
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = getVerifyEmailErrorMessage(error)
|
|
||||||
} finally {
|
|
||||||
resendLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onOtpInput = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
otp.value = target.value.replace(/\D/g, '').slice(0, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME
|
|
||||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :class="[
|
|
||||||
'relative h-screen lg:overflow-hidden bg-primary bg-noise xl:bg-background xl:bg-none',
|
|
||||||
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
|
|
||||||
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
|
|
||||||
]">
|
|
||||||
<div :class="[
|
|
||||||
'p-3 sm:px-8 relative h-full',
|
|
||||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
|
||||||
]">
|
|
||||||
<div class="container relative z-10 mx-auto sm:px-20">
|
|
||||||
<div class="block grid-cols-2 gap-4 xl:grid">
|
|
||||||
<div class="hidden min-h-screen flex-col xl:flex">
|
|
||||||
<a class="flex items-center pt-10" href="">
|
|
||||||
<img class="w-6" :src="logoUrl" alt="logo-RAJD" />
|
|
||||||
<span class="ml-3 text-xl font-medium text-white">
|
|
||||||
{{ appName }} {{ appVersion }}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<div class="my-auto">
|
|
||||||
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
|
|
||||||
<div class="mt-10 text-4xl font-medium leading-tight text-white">
|
|
||||||
Sahkan E-mel
|
|
||||||
</div>
|
|
||||||
<div class="mt-5 text-lg text-white opacity-60">
|
|
||||||
Masukkan kod 6 digit yang dihantar ke e-mel anda.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-10 flex h-screen py-5 xl:my-0 xl:h-auto xl:py-0">
|
|
||||||
<Box raised="double"
|
|
||||||
class="mx-auto my-auto w-full px-5 py-8 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
|
|
||||||
<h2 class="text-center text-2xl font-semibold xl:text-left xl:text-3xl">
|
|
||||||
Verify Email
|
|
||||||
</h2>
|
|
||||||
<p class="mt-2 text-center text-sm opacity-70 xl:text-left">
|
|
||||||
Kod OTP 6 digit telah dihantar ke e-mel anda.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
|
||||||
<AlertTitle>Verification failed</AlertTitle>
|
|
||||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
|
||||||
</AlertRoot>
|
|
||||||
|
|
||||||
<AlertRoot v-if="resendMessage" class="mt-6" variant="primary">
|
|
||||||
<AlertDescription>{{ resendMessage }}</AlertDescription>
|
|
||||||
</AlertRoot>
|
|
||||||
|
|
||||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleVerify">
|
|
||||||
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
|
|
||||||
placeholder="Email" autocomplete="email" required />
|
|
||||||
<Input :model-value="otp"
|
|
||||||
class="box block min-w-full px-5 py-6 xl:min-w-md text-center tracking-[0.5em] text-lg" type="text"
|
|
||||||
inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="000000" autocomplete="one-time-code"
|
|
||||||
required @input="onOtpInput" />
|
|
||||||
|
|
||||||
<div class="mt-5 text-center xl:mt-10 xl:text-left">
|
|
||||||
<Button class="box w-full px-4 py-5" variant="primary" type="submit"
|
|
||||||
:disabled="loading || !canSubmit">
|
|
||||||
{{ loading ? 'Verifying...' : 'Verify Email' }}
|
|
||||||
</Button>
|
|
||||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
|
||||||
:disabled="resendLoading || !email" @click="handleResend">
|
|
||||||
{{ resendLoading ? 'Sending...' : 'Resend Code' }}
|
|
||||||
</Button>
|
|
||||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
|
||||||
@click="router.push({ name: 'login' })">
|
|
||||||
Back to Login
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -13,12 +13,6 @@ export const authPublicRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('./pages/Register.vue'),
|
component: () => import('./pages/Register.vue'),
|
||||||
meta: { module: 'auth' },
|
meta: { module: 'auth' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/verify-email',
|
|
||||||
name: 'verify-email',
|
|
||||||
component: () => import('./pages/VerifyEmail.vue'),
|
|
||||||
meta: { module: 'auth' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
name: 'forgot-password',
|
name: 'forgot-password',
|
||||||
|
|||||||
@@ -5,16 +5,13 @@ import type {
|
|||||||
ForgotPasswordResponse,
|
ForgotPasswordResponse,
|
||||||
LoginCredentials,
|
LoginCredentials,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
LoginVerificationRequiredData,
|
|
||||||
RegisterCredentials,
|
RegisterCredentials,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
ResendVerificationResponse,
|
|
||||||
ResetPasswordPayload,
|
ResetPasswordPayload,
|
||||||
ResetPasswordResponse,
|
ResetPasswordResponse,
|
||||||
|
ResendVerificationResponse,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
SwitchRoleResponse,
|
SwitchRoleResponse,
|
||||||
VerifyEmailPayload,
|
|
||||||
VerifyEmailResponse,
|
|
||||||
} from '../types/auth.types'
|
} from '../types/auth.types'
|
||||||
|
|
||||||
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
|
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||||
@@ -27,13 +24,8 @@ export async function register(credentials: RegisterCredentials): Promise<Regist
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyEmail(payload: VerifyEmailPayload): Promise<VerifyEmailResponse> {
|
export async function sendVerificationEmail(): Promise<ResendVerificationResponse> {
|
||||||
const { data } = await api.post<VerifyEmailResponse>('/verify-email', payload)
|
const { data } = await api.post<ResendVerificationResponse>('/v1/email/verification-notification')
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resendVerificationEmail(email: string): Promise<ResendVerificationResponse> {
|
|
||||||
const { data } = await api.post<ResendVerificationResponse>('/verify-email/resend', { email })
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +65,6 @@ export function getRegisterErrorMessage(error: unknown): string {
|
|||||||
return getApiErrorMessage(error, 'Registration failed. Please try again.')
|
return getApiErrorMessage(error, 'Registration failed. Please try again.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVerifyEmailErrorMessage(error: unknown): string {
|
|
||||||
return getApiErrorMessage(error, 'Email verification failed. Please try again.')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getForgotPasswordErrorMessage(error: unknown): string {
|
export function getForgotPasswordErrorMessage(error: unknown): string {
|
||||||
return getApiErrorMessage(error, 'Gagal menghantar kod OTP. Sila cuba lagi.')
|
return getApiErrorMessage(error, 'Gagal menghantar kod OTP. Sila cuba lagi.')
|
||||||
}
|
}
|
||||||
@@ -85,15 +73,12 @@ export function getResetPasswordErrorMessage(error: unknown): string {
|
|||||||
return getApiErrorMessage(error, 'Gagal menetapkan semula kata laluan. Sila cuba lagi.')
|
return getApiErrorMessage(error, 'Gagal menetapkan semula kata laluan. Sila cuba lagi.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAccountPending(user: { status: string } | null | undefined): boolean {
|
export function isEmailVerified(user: { email_verified_at?: string | null } | null | undefined): boolean {
|
||||||
return user?.status === 'pending'
|
return Boolean(user?.email_verified_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLoginVerificationRequired(
|
export function isAccountPending(user: { status: string } | null | undefined): boolean {
|
||||||
response: LoginResponse,
|
return user?.status === 'pending'
|
||||||
): response is LoginResponse & { data: LoginVerificationRequiredData } {
|
|
||||||
return 'requires_email_verification' in response.data
|
|
||||||
&& response.data.requires_email_verification === true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePostAuthRoute(
|
export function resolvePostAuthRoute(
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface AuthUser {
|
|||||||
birth_date: string | null
|
birth_date: string | null
|
||||||
birth_place: string | null
|
birth_place: string | null
|
||||||
onboarding_completed_at: string | null
|
onboarding_completed_at: string | null
|
||||||
|
email_verified_at: string | null
|
||||||
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
|
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,20 +55,17 @@ export interface LoginSessionData {
|
|||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginVerificationRequiredData {
|
export interface AuthSessionResponse {
|
||||||
email: string
|
|
||||||
requires_email_verification: true
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
data: LoginSessionData | LoginVerificationRequiredData
|
data: LoginSessionData
|
||||||
active_role?: AuthRole | null
|
active_role?: AuthRole | null
|
||||||
can_switch_role?: boolean
|
can_switch_role?: boolean
|
||||||
redirect_path?: string
|
redirect_path?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LoginResponse = AuthSessionResponse
|
||||||
|
|
||||||
export interface SwitchRoleResponse extends SessionResponse {
|
export interface SwitchRoleResponse extends SessionResponse {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
@@ -80,21 +78,7 @@ export interface RegisterCredentials {
|
|||||||
password_confirmation: string
|
password_confirmation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterResponse {
|
export type RegisterResponse = AuthSessionResponse
|
||||||
success: boolean
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
email: string
|
|
||||||
requires_email_verification: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyEmailPayload {
|
|
||||||
email: string
|
|
||||||
otp: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VerifyEmailResponse = LoginResponse
|
|
||||||
|
|
||||||
export interface ResendVerificationResponse {
|
export interface ResendVerificationResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const router = createRouter({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'verify-email', 'membership-application-apply'])
|
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'membership-application-apply'])
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const authStore = useAuthStore(pinia)
|
const authStore = useAuthStore(pinia)
|
||||||
@@ -66,7 +66,6 @@ router.beforeEach(async (to) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect authenticated users away from login/register/verify-email.
|
|
||||||
if (
|
if (
|
||||||
authStore.isAuthenticated &&
|
authStore.isAuthenticated &&
|
||||||
PUBLIC_ROUTE_NAMES.has(routeName) &&
|
PUBLIC_ROUTE_NAMES.has(routeName) &&
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
isAuthenticated: (state) => state.user !== null,
|
isAuthenticated: (state) => state.user !== null,
|
||||||
isAccountPending: (state) => state.user?.status === 'pending',
|
isAccountPending: (state) => state.user?.status === 'pending',
|
||||||
isAccountActive: (state) => state.user?.status === 'active',
|
isAccountActive: (state) => state.user?.status === 'active',
|
||||||
|
isEmailVerified: (state) => Boolean(state.user?.email_verified_at),
|
||||||
roles: (state) => state.user?.roles ?? [],
|
roles: (state) => state.user?.roles ?? [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useBreadcrumb } from '@/composables/useBreadcrumb'
|
import { useBreadcrumb } from '@/composables/useBreadcrumb'
|
||||||
import { useNotifications } from '@/modules/notification'
|
import { useNotifications } from '@/modules/notification'
|
||||||
@@ -14,6 +15,7 @@ import { useFilteredMenu } from '@/composables/useFilteredMenu'
|
|||||||
import { SideMenu } from '@/components/side-menu'
|
import { SideMenu } from '@/components/side-menu'
|
||||||
import { AccountDropdown, AccountTrigger } from '@/components/account-dropdown'
|
import { AccountDropdown, AccountTrigger } from '@/components/account-dropdown'
|
||||||
import { NotificationDropdown } from '@/components/notification-dropdown'
|
import { NotificationDropdown } from '@/components/notification-dropdown'
|
||||||
|
import EmailVerificationBanner from '@/modules/auth/components/EmailVerificationBanner.vue'
|
||||||
import {
|
import {
|
||||||
ScrollAreaRoot,
|
ScrollAreaRoot,
|
||||||
ScrollAreaViewport,
|
ScrollAreaViewport,
|
||||||
@@ -33,6 +35,8 @@ const {
|
|||||||
} = useSideMenu()
|
} = useSideMenu()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const breadcrumbItems = useBreadcrumb([])
|
const breadcrumbItems = useBreadcrumb([])
|
||||||
const filteredMenu = useFilteredMenu(mainMenu)
|
const filteredMenu = useFilteredMenu(mainMenu)
|
||||||
const { unreadCount, fetchNotifications } = useNotifications()
|
const { unreadCount, fetchNotifications } = useNotifications()
|
||||||
@@ -89,6 +93,12 @@ onMounted(() => {
|
|||||||
authStore.fetchSession()
|
authStore.fetchSession()
|
||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
document.addEventListener('click', handleDocumentClick)
|
document.addEventListener('click', handleDocumentClick)
|
||||||
|
|
||||||
|
const verified = route.query.verified
|
||||||
|
if (typeof verified === 'string' && verified) {
|
||||||
|
authStore.fetchSession()
|
||||||
|
router.replace({ query: { ...route.query, verified: undefined } })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -224,6 +234,10 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<EmailVerificationBanner
|
||||||
|
v-if="authStore.isAuthenticated && !authStore.isEmailVerified"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user