Feature/phone register (#11)

Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-07-14 12:03:22 +08:00
parent 1e50e3d19f
commit b05e074456
160 changed files with 6497 additions and 759 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>
+14 -6
View File
@@ -4,28 +4,36 @@ export {
login,
logout,
register,
verifyEmail,
resendVerificationEmail,
sendPhoneVerificationOtp,
verifyPhoneVerificationOtp,
sendAuthenticatedPhoneVerificationOtp,
verifyAuthenticatedPhoneVerificationOtp,
sendVerificationEmail,
requestForgotPassword,
resetPassword,
fetchCurrentUser,
getAuthErrorMessage,
getRegisterErrorMessage,
getVerifyEmailErrorMessage,
getPhoneVerificationErrorMessage,
getForgotPasswordErrorMessage,
getResetPasswordErrorMessage,
resolvePostLoginRoute,
resolvePostAuthRoute,
isAccountPending,
isLoginVerificationRequired,
isEmailVerified,
isPhoneVerified,
} from './services/auth.service'
export type {
LoginCredentials,
LoginResponse,
RegisterCredentials,
RegisterResponse,
VerifyEmailPayload,
VerifyEmailResponse,
SendPhoneVerificationOtpPayload,
SendPhoneVerificationOtpResponse,
VerifyPhoneVerificationOtpPayload,
VerifyPhoneVerificationOtpResponse,
VerifyAuthenticatedPhoneVerificationOtpResponse,
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>
+8 -12
View File
@@ -12,6 +12,7 @@ import {
login,
resolvePostAuthRoute,
} from '@/modules/auth'
import { HelpdeskFab } from '@/modules/feedback'
import { useAuthStore } from '@/stores/auth'
import illustrationUrl from '@/assets/images/logo.svg'
@@ -47,18 +48,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 {
@@ -76,6 +71,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
'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',
]">
<HelpdeskFab />
<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%]',
+334 -61
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
@@ -7,12 +7,33 @@ 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 {
getPhoneVerificationErrorMessage,
getRegisterErrorMessage,
register,
resolvePostAuthRoute,
sendPhoneVerificationOtp,
verifyPhoneVerificationOtp,
} from '@/modules/auth'
import { useAuthStore } from '@/stores/auth'
import { HelpdeskFab } from '@/modules/feedback'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
const steps = [
{ id: 1, label: 'No. Telefon' },
{ id: 2, label: 'Sahkan OTP' },
{ id: 3, label: 'Maklumat Akaun' },
{ id: 4, label: 'Kata Laluan' },
] as const
const router = useRouter()
const authStore = useAuthStore()
const currentStep = ref(1)
const phoneNumber = ref('')
const otp = ref('')
const phoneVerificationToken = ref('')
const name = ref('')
const email = ref('')
const icNumber = ref('')
@@ -21,6 +42,61 @@ const passwordConfirmation = ref('')
const termsAccepted = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const stepTitle = computed(() => {
switch (currentStep.value) {
case 1:
return 'Sahkan Nombor Telefon'
case 2:
return 'Masukkan Kod OTP'
case 3:
return 'Maklumat Akaun'
default:
return 'Tetapkan Kata Laluan'
}
})
const stepDescription = computed(() => {
switch (currentStep.value) {
case 1:
return 'Masukkan nombor telefon anda untuk menerima kod OTP melalui SMS.'
case 2:
return 'Masukkan kod OTP 6 digit yang telah dihantar ke nombor telefon anda.'
case 3:
return 'Lengkapkan maklumat asas akaun anda.'
default:
return 'Tetapkan kata laluan dan bersetuju dengan terma pendaftaran.'
}
})
const primaryActionLabel = computed(() => {
if (loading.value) {
switch (currentStep.value) {
case 1:
return 'Menghantar OTP...'
case 2:
return 'Mengesahkan OTP...'
case 3:
return 'Seterusnya'
default:
return 'Mendaftar...'
}
}
switch (currentStep.value) {
case 1:
return 'Hantar OTP'
case 2:
return 'Sahkan OTP'
case 3:
return 'Seterusnya'
default:
return 'Daftar'
}
})
const inputClass = 'box block min-w-full px-4 py-4 xl:min-w-md'
const handleNameInput = () => {
name.value = sanitizeNameInput(name.value)
@@ -30,13 +106,102 @@ const handleIcNumberInput = () => {
icNumber.value = sanitizeIcNumberInput(icNumber.value)
}
const handlePhoneInput = () => {
phoneNumber.value = phoneNumber.value.replace(/[^\d+]/g, '')
phoneVerificationToken.value = ''
}
const handleOtpInput = () => {
otp.value = otp.value.replace(/\D/g, '').slice(0, 6)
}
const clearMessages = () => {
errorMessage.value = ''
successMessage.value = ''
}
const handleSendOtp = async () => {
clearMessages()
loading.value = true
try {
const response = await sendPhoneVerificationOtp({
phone_number: phoneNumber.value,
})
successMessage.value = response.message
currentStep.value = 2
} catch (error) {
errorMessage.value = getPhoneVerificationErrorMessage(error)
} finally {
loading.value = false
}
}
const handleVerifyOtp = async () => {
clearMessages()
loading.value = true
try {
const response = await verifyPhoneVerificationOtp({
phone_number: phoneNumber.value,
otp: otp.value,
})
phoneNumber.value = response.data.phone_number
phoneVerificationToken.value = response.data.verification_token
successMessage.value = response.message
currentStep.value = 3
} catch (error) {
errorMessage.value = getPhoneVerificationErrorMessage(error)
} finally {
loading.value = false
}
}
const validateAccountStep = (): boolean => {
clearMessages()
if (!name.value.trim()) {
errorMessage.value = 'Nama penuh diperlukan.'
return false
}
if (!email.value.trim()) {
errorMessage.value = 'Emel diperlukan.'
return false
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
errorMessage.value = 'Emel tidak sah.'
return false
}
if (!icNumber.value.trim()) {
errorMessage.value = 'No. kad pengenalan diperlukan.'
return false
}
return true
}
const handleRegister = async () => {
if (!password.value || !passwordConfirmation.value) {
errorMessage.value = 'Kata laluan diperlukan.'
return
}
if (password.value !== passwordConfirmation.value) {
errorMessage.value = 'Pengesahan kata laluan tidak sepadan.'
return
}
if (!termsAccepted.value) {
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.'
return
}
errorMessage.value = ''
clearMessages()
loading.value = true
try {
@@ -44,34 +209,88 @@ const handleRegister = async () => {
name: name.value,
email: email.value,
ic_number: icNumber.value,
phone_number: phoneNumber.value,
phone_verification_token: phoneVerificationToken.value,
password: password.value,
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 {
loading.value = false
}
}
}
const handlePrimaryAction = async () => {
if (currentStep.value === 1) {
await handleSendOtp()
return
}
if (currentStep.value === 2) {
await handleVerifyOtp()
return
}
if (currentStep.value === 3) {
if (!validateAccountStep()) {
return
}
currentStep.value = 4
return
}
await handleRegister()
}
const goPrevious = () => {
clearMessages()
if (currentStep.value > 1) {
currentStep.value -= 1
}
}
const stepButtonClass = (stepId: number) => {
if (stepId === currentStep.value) {
return 'size-10 rounded-full shadow-none'
}
if (stepId < currentStep.value) {
return 'size-10 rounded-full shadow-none bg-primary text-primary-foreground'
}
return 'bg-background border border-foreground/15 shadow-none size-10 rounded-full'
}
const stepLabelClass = (stepId: number) => {
return stepId === currentStep.value
? 'mt-2 text-xs font-medium text-primary'
: 'mt-2 text-xs opacity-70'
}
</script>
<template>
<div :class="[
'relative h-screen lg:overflow-hidden bg-primary bg-noise xl:bg-background xl:bg-none',
'relative min-h-dvh bg-primary bg-noise xl:h-screen xl:overflow-hidden 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',
]">
<HelpdeskFab />
<div :class="[
'p-3 sm:px-8 relative h-full',
'relative p-3 sm:px-8',
'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="block xl:grid xl:grid-cols-2 xl:gap-4">
<div class="hidden min-h-screen flex-col xl:flex">
<div class="my-auto">
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
@@ -84,61 +303,115 @@ const handleRegister = async () => {
</div>
</div>
<div class="my-10 flex h-screen py-5 xl:my-0 xl:h-auto xl:py-0">
<div class="my-6 py-4 xl:my-0 xl:flex xl:min-h-screen xl:items-center 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">
class="mx-auto w-full px-5 py-6 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">Daftar Akaun</h2>
<div class="mt-2 text-center opacity-70 xl:hidden">
Daftar Akaun
<div
class="before:bg-foreground/10 relative mt-5 flex flex-row justify-between gap-1 px-1 before:absolute before:bottom-[calc(50%-0.75rem)] before:left-[12%] before:right-[12%] before:h-0.5 before:w-auto">
<div v-for="step in steps" :key="step.id" class="z-10 flex flex-1 flex-col items-center text-center">
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'primary' : 'ghost'"
type="button">
{{ step.id }}
</Button>
<div :class="stepLabelClass(step.id)">
{{ step.label }}
</div>
</div>
</div>
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
<AlertTitle>Daftar gagal</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription>
</AlertRoot>
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleRegister">
<Input v-model="name" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
placeholder="Nama Penuh" autocomplete="name" required @input="handleNameInput" />
<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 v-model="icNumber" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
inputmode="numeric" maxlength="15" placeholder="Contoh: 900101011234" required
@input="handleIcNumberInput" />
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md" type="password"
placeholder="Kata Laluan" autocomplete="new-password" minlength="8" required />
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md"
placeholder="Sahkan Kata Laluan" autocomplete="new-password" minlength="8" required />
<div class="flex text-xs sm:text-sm">
<CheckboxRoot :checked="termsAccepted"
@checked-change="({ checked }) => (termsAccepted = checked === true)">
<CheckboxControl />
<CheckboxLabel>
Dengan mendaftar, anda bersetuju dengan
<RouterLink class="text-primary ml-1" to="/privacy-policy">
Dasar Privasi
</RouterLink>
&amp;
<RouterLink class="text-primary ml-1" to="/terms">
Terma dan Syarat
</RouterLink>
.
</CheckboxLabel>
</CheckboxRoot>
<div class="mt-5 border-t border-foreground/10 pt-5">
<div class="text-center xl:text-left">
<div class="text-base font-medium">{{ stepTitle }}</div>
<div class="mt-1 text-sm opacity-70">{{ stepDescription }}</div>
</div>
<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 || !termsAccepted">
{{ loading ? 'Mendaftar...' : 'Daftar' }}
</Button>
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
@click="router.push({ name: 'login' })">
Log masuk
</Button>
</div>
</form>
<AlertRoot v-if="errorMessage" class="mt-4 py-3" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription>
</AlertRoot>
<AlertRoot v-if="successMessage" class="mt-4 py-3" variant="success">
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ successMessage }}</AlertDescription>
</AlertRoot>
<form class="mt-4 flex flex-col gap-5" @submit.prevent="handlePrimaryAction">
<div class="flex flex-col gap-3">
<template v-if="currentStep === 1">
<Input v-model="phoneNumber" :class="inputClass" type="tel" inputmode="tel"
placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" required
@input="handlePhoneInput" />
</template>
<template v-else-if="currentStep === 2">
<Input v-model="otp" :class="`${inputClass} text-center tracking-[0.4em]`" type="text"
inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code" required
@input="handleOtpInput" />
<Button class="box w-full px-4 py-4" look="outline" type="button" :disabled="loading"
@click="handleSendOtp">
Hantar Semula OTP
</Button>
</template>
<template v-else-if="currentStep === 3">
<div class="rounded-lg border border-foreground/10 bg-foreground/5 px-4 py-3 text-sm">
<span class="opacity-70">Telefon disahkan:</span>
<span class="ml-1 font-medium">{{ phoneNumber }}</span>
</div>
<Input v-model="name" :class="inputClass" type="text" placeholder="Nama Penuh" autocomplete="name"
required @input="handleNameInput" />
<Input v-model="email" :class="inputClass" type="email" placeholder="Email" autocomplete="email"
required />
<Input v-model="icNumber" :class="inputClass" type="text" inputmode="numeric" maxlength="15"
placeholder="Contoh: 900101011234" required @input="handleIcNumberInput" />
</template>
<template v-else>
<PasswordInput v-model="password" :class="inputClass" placeholder="Kata Laluan"
autocomplete="new-password" minlength="8" required />
<PasswordInput v-model="passwordConfirmation" :class="inputClass" placeholder="Sahkan Kata Laluan"
autocomplete="new-password" minlength="8" required />
<div class="flex text-xs sm:text-sm">
<CheckboxRoot :checked="termsAccepted"
@checked-change="({ checked }) => (termsAccepted = checked === true)">
<CheckboxControl />
<CheckboxLabel>
Dengan mendaftar, anda bersetuju dengan
<RouterLink class="text-primary ml-1" to="/privacy-policy">
Dasar Privasi
</RouterLink>
&amp;
<RouterLink class="text-primary ml-1" to="/terms">
Terma dan Syarat
</RouterLink>
.
</CheckboxLabel>
</CheckboxRoot>
</div>
</template>
</div>
<div class="space-y-3">
<div class="flex gap-3">
<Button v-if="currentStep > 1" class="box w-full px-4 py-4" look="outline" type="button"
:disabled="loading" @click="goPrevious">
Sebelum
</Button>
<Button class="box w-full px-4 py-4" variant="primary" type="submit"
:disabled="loading || (currentStep === 4 && !termsAccepted)">
{{ primaryActionLabel }}
</Button>
</div>
<button type="button" class="w-full text-sm opacity-70 hover:opacity-100"
@click="router.push({ name: 'login' })">
Sudah mempunyai akaun? Log masuk
</button>
</div>
</form>
</div>
</Box>
</div>
</div>
-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',
+55 -17
View File
@@ -5,16 +5,18 @@ import type {
ForgotPasswordResponse,
LoginCredentials,
LoginResponse,
LoginVerificationRequiredData,
RegisterCredentials,
RegisterResponse,
ResendVerificationResponse,
ResetPasswordPayload,
ResetPasswordResponse,
ResendVerificationResponse,
SessionResponse,
SendPhoneVerificationOtpPayload,
SendPhoneVerificationOtpResponse,
SwitchRoleResponse,
VerifyEmailPayload,
VerifyEmailResponse,
VerifyPhoneVerificationOtpPayload,
VerifyPhoneVerificationOtpResponse,
VerifyAuthenticatedPhoneVerificationOtpResponse,
} from '../types/auth.types'
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
@@ -27,13 +29,48 @@ 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)
export async function sendPhoneVerificationOtp(
payload: SendPhoneVerificationOtpPayload,
): Promise<SendPhoneVerificationOtpResponse> {
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
'/phone-verification/send',
payload,
)
return data
}
export async function resendVerificationEmail(email: string): Promise<ResendVerificationResponse> {
const { data } = await api.post<ResendVerificationResponse>('/verify-email/resend', { email })
export async function verifyPhoneVerificationOtp(
payload: VerifyPhoneVerificationOtpPayload,
): Promise<VerifyPhoneVerificationOtpResponse> {
const { data } = await api.post<VerifyPhoneVerificationOtpResponse>(
'/phone-verification/verify',
payload,
)
return data
}
export async function sendAuthenticatedPhoneVerificationOtp(
payload: SendPhoneVerificationOtpPayload,
): Promise<SendPhoneVerificationOtpResponse> {
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
'/v1/phone-verification/send',
payload,
)
return data
}
export async function verifyAuthenticatedPhoneVerificationOtp(
payload: VerifyPhoneVerificationOtpPayload,
): Promise<VerifyAuthenticatedPhoneVerificationOtpResponse> {
const { data } = await api.post<VerifyAuthenticatedPhoneVerificationOtpResponse>(
'/v1/phone-verification/verify',
payload,
)
return data
}
export async function sendVerificationEmail(): Promise<ResendVerificationResponse> {
const { data } = await api.post<ResendVerificationResponse>('/v1/email/verification-notification')
return data
}
@@ -73,8 +110,8 @@ 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 getPhoneVerificationErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Gagal mengesahkan nombor telefon. Sila cuba lagi.')
}
export function getForgotPasswordErrorMessage(error: unknown): string {
@@ -85,15 +122,16 @@ 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 isPhoneVerified(user: { phone_verified_at?: string | null } | null | undefined): boolean {
return Boolean(user?.phone_verified_at)
}
export function isAccountPending(user: { status: string } | null | undefined): boolean {
return user?.status === 'pending'
}
export function resolvePostAuthRoute(
+33 -17
View File
@@ -34,6 +34,7 @@ export interface AuthUser {
ic_number: string | null
position: string | null
phone_number: string | null
phone_verified_at: string | null
image_url: string | null
member_number: number | null
member_type: string | null
@@ -45,6 +46,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 +56,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
}
@@ -76,25 +75,42 @@ export interface RegisterCredentials {
name: string
email: string
ic_number: string
phone_number: string
phone_verification_token: string
password: string
password_confirmation: string
}
export interface RegisterResponse {
success: boolean
message: string
data: {
email: string
requires_email_verification: boolean
}
export type RegisterResponse = AuthSessionResponse
export interface SendPhoneVerificationOtpPayload {
phone_number: string
}
export interface VerifyEmailPayload {
email: string
export interface SendPhoneVerificationOtpResponse {
success: boolean
message: string
}
export interface VerifyPhoneVerificationOtpPayload {
phone_number: string
otp: string
}
export type VerifyEmailResponse = LoginResponse
export interface VerifyPhoneVerificationOtpResponse {
success: boolean
message: string
data: {
phone_number: string
verification_token: string
}
}
export interface VerifyAuthenticatedPhoneVerificationOtpResponse {
success: boolean
message: string
data: AuthUser
}
export interface ResendVerificationResponse {
success: boolean