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:
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%]',
|
||||
|
||||
@@ -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>
|
||||
&
|
||||
<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>
|
||||
&
|
||||
<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>
|
||||
|
||||
@@ -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'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
name: 'verify-email',
|
||||
component: () => import('./pages/VerifyEmail.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user