DONE: replace email verification by link instead of otp

This commit is contained in:
ISMAIL MASSERAN
2026-07-12 10:03:31 +08:00
parent 1e50e3d19f
commit 431bbee17c
30 changed files with 263 additions and 593 deletions
+4 -4
View File
@@ -5,15 +5,15 @@
## Improvement
[ ] mesyuarat anggota tertinggi
[ ] penyata anggota
[ ] daftar anggota
[x] daftar anggota
[ ] baki pinjaman - transaction history: tarik dari ubs
[ ] pembiayaan anggota
[ ] sumbangan
[ ] wasi/penama
[x] wasi/penama
[ ] pendaftaran anggota/meneruskan anggota/pencen
[ ] daftar lembaga (backdated)
[ ] boleh print semua borang
[ ] jana surat lepas lulus anggota
[x] boleh print semua borang
[x] jana surat lepas lulus anggota
## Present to Boss (2/7/2026)
[x] discuss logo baru MyKOPKB
+4
View File
@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
FRONTEND_URL=http://localhost:5173
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -87,3 +88,6 @@ AUTH_COOKIE_EXPOSE_TOKEN=false
ACTIVE_ROLE_PREFER_MEMBER=true
ACTIVE_ROLE_MEMBER_REDIRECT=/profile
ACTIVE_ROLE_ADMIN_REDIRECT=/profile
EMAIL_VERIFICATION_EXPIRE_MINUTES=60
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
@@ -12,7 +12,6 @@ use Laravel\Fortify\Contracts\CreatesNewUsers;
use Modules\Auth\Entities\User;
use Modules\Role\Entities\Role;
use Modules\User\Notifications\UserActivationNotification;
use Modules\Auth\Services\EmailVerificationOtpService;
use Exception;
class CreateNewUser implements CreatesNewUsers
@@ -54,38 +53,6 @@ class CreateNewUser implements CreatesNewUsers
$user->assignRole($role);
}
app(EmailVerificationOtpService::class)->send($user);
// Send notification to admins if user requires activation
if ($user->status === 'pending') {
$this->notifyAdminsForActivation($user);
}
return $user;
}
/**
* Notify admins about new user requiring activation
*/
private function notifyAdminsForActivation(User $newUser): void
{
try {
$adminRoles = ['PENTADBIR', 'PS 2 KJC', 'PS 2 ALAT'];
// Get users with specific roles plus admins (PENTADBIR and DEVELOPER)
$adminUsers = $this->getUsersWithRolesAndAdmins($adminRoles);
$sender = auth()->user() ?? $newUser; // Use current user as sender, or new user if no auth
foreach ($adminUsers as $admin) {
try {
$admin->notify(new UserActivationNotification($newUser, $sender));
} catch (Exception $e) {
Log::error('Failed to send user activation notification: '.$e->getMessage());
}
}
} catch (Exception $e) {
Log::error('Failed to notify admins for user activation: '.$e->getMessage());
}
}
}
@@ -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
);
}
}
@@ -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);
}
}
+9 -11
View File
@@ -2,7 +2,8 @@
namespace Modules\Auth\Entities;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -22,12 +23,13 @@ use Modules\Role\Entities\Role;
use Modules\User\Entities\Address;
use Modules\User\Entities\BankDetail;
use Modules\User\Entities\Employment;
use Modules\Auth\Notifications\VerifyEmailNotification;
use Modules\User\Entities\Heir;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<\Modules\Auth\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes;
use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, MustVerifyEmailTrait, Notifiable, SoftDeletes;
protected $table = 'users';
@@ -301,9 +303,9 @@ class User extends Authenticatable
return $currentUser->hasPermissionTo('menyamar pengguna');
}
public function hasVerifiedEmail(): bool
public function sendEmailVerificationNotification(): void
{
return $this->email_verified_at !== null;
$this->notify(new VerifyEmailNotification);
}
/**
@@ -315,15 +317,11 @@ class User extends Authenticatable
}
/**
* Whether credentials are valid for issuing a session (includes verified pending users).
* Whether credentials are valid for issuing a session (includes pending users awaiting admin activation).
*/
public function canAuthenticate(): bool
{
if ($this->canLogin()) {
return true;
}
return $this->hasVerifiedEmail() && $this->status === 'pending';
return in_array($this->status, ['active', 'pending'], true);
}
/**
@@ -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,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()),
]
);
}
}
+7 -4
View File
@@ -11,10 +11,9 @@ use Laravel\Fortify\Http\Controllers\RegisteredUserController;
Route::post('/register', [RegisteredUserController::class, 'store']);
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools');
Route::post('/verify-email', [EmailVerificationController::class, 'verify'])
->middleware('throttle:email-verification');
Route::post('/verify-email/resend', [EmailVerificationController::class, 'resend'])
->middleware('throttle:email-verification-resend');
Route::get('/email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])
->middleware('throttle:email-verification')
->name('verification.verify');
Route::post('/forgot-password', [PasswordResetController::class, 'requestOtp'])
->middleware('throttle:password-reset-request');
@@ -30,4 +29,8 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
// logout
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']);
// optional email verification (post-login)
Route::post('/email/verification-notification', [EmailVerificationController::class, 'send'])
->middleware('throttle:email-verification-resend');
});
@@ -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\Validation\ValidationException;
use Modules\Auth\Entities\User;
use Modules\Auth\Services\EmailVerificationOtpService;
use Modules\MembershipApplication\Entities\MembershipApplication;
use Modules\MembershipApplication\Enums\ApplicationStatus;
use Modules\MembershipApplication\Enums\BoardDecision;
@@ -513,8 +512,6 @@ class MembershipApplicationService
]);
}
app(EmailVerificationOtpService::class)->send($user);
return [
'user' => $user,
'plainPassword' => $plainPassword,
+2 -6
View File
@@ -57,9 +57,9 @@ class FortifyServiceProvider extends ServiceProvider
});
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) {
@@ -87,10 +87,6 @@ class FortifyServiceProvider extends ServiceProvider
$shouldBypassPassword = config('app.env', 'local');
if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) {
if (! $user->hasVerifiedEmail()) {
return $user;
}
if (! $user->canAuthenticate()) {
throw ValidationException::withMessages([
'email' => [$user->getLoginRestrictionMessage()],
+4 -4
View File
@@ -113,13 +113,13 @@ return [
/*
|--------------------------------------------------------------------------
| Email Verification OTP
| Email Verification Link
|--------------------------------------------------------------------------
*/
'email_verification' => [
'expiry_minutes' => (int) env('EMAIL_VERIFICATION_OTP_EXPIRY_MINUTES', 10),
'max_attempts' => (int) env('EMAIL_VERIFICATION_OTP_MAX_ATTEMPTS', 5),
'verification' => [
'expire_minutes' => (int) env('EMAIL_VERIFICATION_EXPIRE_MINUTES', 60),
'frontend_redirect_path' => env('EMAIL_VERIFICATION_REDIRECT_PATH', '/profile'),
],
/*
@@ -6,10 +6,12 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('email_verification_otps');
}
public function down(): void
{
Schema::create('email_verification_otps', function (Blueprint $table) {
$table->uuid('id')->primary();
@@ -18,16 +20,6 @@ return new class extends Migration
$table->timestamp('expires_at');
$table->unsignedTinyInteger('attempts')->default(0);
$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>
+3 -6
View File
@@ -4,28 +4,25 @@ export {
login,
logout,
register,
verifyEmail,
resendVerificationEmail,
sendVerificationEmail,
requestForgotPassword,
resetPassword,
fetchCurrentUser,
getAuthErrorMessage,
getRegisterErrorMessage,
getVerifyEmailErrorMessage,
getForgotPasswordErrorMessage,
getResetPasswordErrorMessage,
resolvePostLoginRoute,
resolvePostAuthRoute,
isAccountPending,
isLoginVerificationRequired,
isEmailVerified,
} from './services/auth.service'
export type {
LoginCredentials,
LoginResponse,
RegisterCredentials,
RegisterResponse,
VerifyEmailPayload,
VerifyEmailResponse,
ResendVerificationResponse,
ForgotPasswordPayload,
ForgotPasswordResponse,
ResetPasswordPayload,
+13 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
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'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const loggingOut = ref(false)
@@ -44,6 +45,12 @@ const handleLogout = async () => {
}
onMounted(() => {
const verified = route.query.verified
if (typeof verified === 'string' && verified) {
authStore.fetchSession()
router.replace({ query: { ...route.query, verified: undefined } })
}
statusPollInterval = setInterval(checkActivationStatus, 30000)
})
@@ -92,21 +99,21 @@ const appVersion = import.meta.env.VITE_APP_VERSION
</p>
<AlertRoot class="mt-8" variant="primary">
<AlertTitle>E-mel disahkan</AlertTitle>
<AlertTitle>Menunggu pengaktifan</AlertTitle>
<AlertDescription>
Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem. Anda akan dapat
mengakses sistem selepas akaun diaktifkan.
Pendaftaran anda berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir
sistem. Anda akan dapat mengakses sistem selepas akaun diaktifkan.
</AlertDescription>
</AlertRoot>
<div class="mt-8 flex flex-col gap-4">
<Button class="box w-full px-4 py-5" variant="primary" type="button" :disabled="checkingStatus"
@click="checkActivationStatus">
{{ checkingStatus ? 'Checking...' : 'Semak Status' }}
{{ checkingStatus ? 'Menyemak...' : 'Semak Status' }}
</Button>
<Button class="box w-full px-4 py-5" look="outline" type="button" :disabled="loggingOut"
@click="handleLogout">
{{ loggingOut ? 'Logging out...' : 'Log Keluar' }}
{{ loggingOut ? 'Log keluar...' : 'Log Keluar' }}
</Button>
</div>
</Box>
+6 -12
View File
@@ -47,18 +47,12 @@ const handleLogin = async () => {
remember: remember.value,
})
const loginData = response.data
if ('requires_email_verification' in loginData) {
await router.push({
name: 'verify-email',
query: { email: loginData.email },
})
return
}
authStore.setSession(loginData.user, response.active_role ?? null, response.can_switch_role ?? false)
await router.push(resolvePostAuthRoute(loginData.user, response.redirect_path))
authStore.setSession(
response.data.user,
response.active_role ?? null,
response.can_switch_role ?? false,
)
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
} catch (error) {
errorMessage.value = getAuthErrorMessage(error)
} finally {
+9 -5
View File
@@ -7,11 +7,13 @@ import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/ch
import { Input } from '@/components/ui/input'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
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 illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
const authStore = useAuthStore()
const name = ref('')
const email = ref('')
@@ -48,10 +50,12 @@ const handleRegister = async () => {
password_confirmation: passwordConfirmation.value,
})
await router.push({
name: 'verify-email',
query: { email: response.data.email },
})
authStore.setSession(
response.data.user,
response.active_role ?? null,
response.can_switch_role ?? false,
)
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
} catch (error) {
errorMessage.value = getRegisterErrorMessage(error)
} finally {
-164
View File
@@ -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>
-6
View File
@@ -13,12 +13,6 @@ export const authPublicRoutes: RouteRecordRaw[] = [
component: () => import('./pages/Register.vue'),
meta: { module: 'auth' },
},
{
path: '/verify-email',
name: 'verify-email',
component: () => import('./pages/VerifyEmail.vue'),
meta: { module: 'auth' },
},
{
path: '/forgot-password',
name: 'forgot-password',
+7 -22
View File
@@ -5,16 +5,13 @@ import type {
ForgotPasswordResponse,
LoginCredentials,
LoginResponse,
LoginVerificationRequiredData,
RegisterCredentials,
RegisterResponse,
ResendVerificationResponse,
ResetPasswordPayload,
ResetPasswordResponse,
ResendVerificationResponse,
SessionResponse,
SwitchRoleResponse,
VerifyEmailPayload,
VerifyEmailResponse,
} from '../types/auth.types'
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
@@ -27,13 +24,8 @@ export async function register(credentials: RegisterCredentials): Promise<Regist
return data
}
export async function verifyEmail(payload: VerifyEmailPayload): Promise<VerifyEmailResponse> {
const { data } = await api.post<VerifyEmailResponse>('/verify-email', payload)
return data
}
export async function resendVerificationEmail(email: string): Promise<ResendVerificationResponse> {
const { data } = await api.post<ResendVerificationResponse>('/verify-email/resend', { email })
export async function sendVerificationEmail(): Promise<ResendVerificationResponse> {
const { data } = await api.post<ResendVerificationResponse>('/v1/email/verification-notification')
return data
}
@@ -73,10 +65,6 @@ export function getRegisterErrorMessage(error: unknown): string {
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 {
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.')
}
export function isAccountPending(user: { status: string } | null | undefined): boolean {
return user?.status === 'pending'
export function isEmailVerified(user: { email_verified_at?: string | null } | null | undefined): boolean {
return Boolean(user?.email_verified_at)
}
export function isLoginVerificationRequired(
response: LoginResponse,
): response is LoginResponse & { data: LoginVerificationRequiredData } {
return 'requires_email_verification' in response.data
&& response.data.requires_email_verification === true
export function isAccountPending(user: { status: string } | null | undefined): boolean {
return user?.status === 'pending'
}
export function resolvePostAuthRoute(
+6 -22
View File
@@ -45,6 +45,7 @@ export interface AuthUser {
birth_date: string | null
birth_place: string | null
onboarding_completed_at: string | null
email_verified_at: string | null
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
}
@@ -54,20 +55,17 @@ export interface LoginSessionData {
expires_at: string
}
export interface LoginVerificationRequiredData {
email: string
requires_email_verification: true
}
export interface LoginResponse {
export interface AuthSessionResponse {
success: boolean
message: string
data: LoginSessionData | LoginVerificationRequiredData
data: LoginSessionData
active_role?: AuthRole | null
can_switch_role?: boolean
redirect_path?: string
}
export type LoginResponse = AuthSessionResponse
export interface SwitchRoleResponse extends SessionResponse {
message: string
}
@@ -80,21 +78,7 @@ export interface RegisterCredentials {
password_confirmation: string
}
export interface RegisterResponse {
success: boolean
message: string
data: {
email: string
requires_email_verification: boolean
}
}
export interface VerifyEmailPayload {
email: string
otp: string
}
export type VerifyEmailResponse = LoginResponse
export type RegisterResponse = AuthSessionResponse
export interface ResendVerificationResponse {
success: boolean
+1 -2
View File
@@ -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) => {
const authStore = useAuthStore(pinia)
@@ -66,7 +66,6 @@ router.beforeEach(async (to) => {
return true
}
// Redirect authenticated users away from login/register/verify-email.
if (
authStore.isAuthenticated &&
PUBLIC_ROUTE_NAMES.has(routeName) &&
+1
View File
@@ -36,6 +36,7 @@ export const useAuthStore = defineStore('auth', {
isAuthenticated: (state) => state.user !== null,
isAccountPending: (state) => state.user?.status === 'pending',
isAccountActive: (state) => state.user?.status === 'active',
isEmailVerified: (state) => Boolean(state.user?.email_verified_at),
roles: (state) => state.user?.roles ?? [],
},
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useBreadcrumb } from '@/composables/useBreadcrumb'
import { useNotifications } from '@/modules/notification'
@@ -14,6 +15,7 @@ import { useFilteredMenu } from '@/composables/useFilteredMenu'
import { SideMenu } from '@/components/side-menu'
import { AccountDropdown, AccountTrigger } from '@/components/account-dropdown'
import { NotificationDropdown } from '@/components/notification-dropdown'
import EmailVerificationBanner from '@/modules/auth/components/EmailVerificationBanner.vue'
import {
ScrollAreaRoot,
ScrollAreaViewport,
@@ -33,6 +35,8 @@ const {
} = useSideMenu()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const breadcrumbItems = useBreadcrumb([])
const filteredMenu = useFilteredMenu(mainMenu)
const { unreadCount, fetchNotifications } = useNotifications()
@@ -89,6 +93,12 @@ onMounted(() => {
authStore.fetchSession()
fetchNotifications()
document.addEventListener('click', handleDocumentClick)
const verified = route.query.verified
if (typeof verified === 'string' && verified) {
authStore.fetchSession()
router.replace({ query: { ...route.query, verified: undefined } })
}
})
onUnmounted(() => {
@@ -224,6 +234,10 @@ onUnmounted(() => {
</div>
</div>
</div>
<EmailVerificationBanner
v-if="authStore.isAuthenticated && !authStore.isEmailVerified"
class="mb-4"
/>
<RouterView />
</div>
</div>