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
@@ -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