DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -6,10 +6,14 @@ export {
|
||||
register,
|
||||
verifyEmail,
|
||||
resendVerificationEmail,
|
||||
requestForgotPassword,
|
||||
resetPassword,
|
||||
fetchCurrentUser,
|
||||
getAuthErrorMessage,
|
||||
getRegisterErrorMessage,
|
||||
getVerifyEmailErrorMessage,
|
||||
getForgotPasswordErrorMessage,
|
||||
getResetPasswordErrorMessage,
|
||||
resolvePostLoginRoute,
|
||||
resolvePostAuthRoute,
|
||||
isAccountPending,
|
||||
@@ -22,6 +26,10 @@ export type {
|
||||
RegisterResponse,
|
||||
VerifyEmailPayload,
|
||||
VerifyEmailResponse,
|
||||
ForgotPasswordPayload,
|
||||
ForgotPasswordResponse,
|
||||
ResetPasswordPayload,
|
||||
ResetPasswordResponse,
|
||||
SessionResponse,
|
||||
AuthUser,
|
||||
AuthRole,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { 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 { getForgotPasswordErrorMessage, requestForgotPassword } from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await requestForgotPassword({ email: email.value })
|
||||
successMessage.value = response.message
|
||||
|
||||
await router.push({
|
||||
name: 'reset-password',
|
||||
query: { email: email.value },
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = getForgotPasswordErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<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">
|
||||
Lupa Kata Laluan
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Kod OTP akan dihantar jika e-mel wujud dalam sistem.
|
||||
</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">
|
||||
Lupa Kata Laluan
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm opacity-70 xl:text-left">
|
||||
Masukkan e-mel akaun anda. Kod OTP akan dihantar jika e-mel wujud dalam sistem.
|
||||
</p>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" class="mt-6" variant="primary">
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleSubmit">
|
||||
<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 />
|
||||
|
||||
<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">
|
||||
{{ loading ? 'Menghantar...' : 'Hantar Kod OTP' }}
|
||||
</Button>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
@click="router.push({ name: 'login' })">
|
||||
Kembali ke Log Masuk
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
@@ -16,6 +16,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
@@ -23,9 +24,20 @@ const password = ref('')
|
||||
const remember = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.reset === 'success') {
|
||||
const message = route.query.message
|
||||
successMessage.value = typeof message === 'string' && message
|
||||
? message
|
||||
: 'Kata laluan anda telah berjaya ditetapkan semula. Sila log masuk.'
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
@@ -78,7 +90,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
Selamat Datang
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Sistem Rejimen Askar Jurutera Diraja (SUTERA)
|
||||
Sistem Informasi Koperasi Online (MyKOPKB) 1.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +104,10 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
Selamat Datang
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="successMessage" class="mt-6" variant="primary">
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Login failed</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
@@ -109,7 +125,13 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
<CheckboxLabel>Ingat saya</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
<a class="opacity-70" href="">Lupa Password?</a>
|
||||
<button
|
||||
type="button"
|
||||
class="opacity-70 hover:opacity-100"
|
||||
@click="router.push({ name: 'forgot-password' })"
|
||||
>
|
||||
Lupa Password?
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-5 text-center xl:mt-10 xl:text-left">
|
||||
<Button class="login-button box w-full px-4 py-5" variant="primary" type="submit" :disabled="loading">
|
||||
|
||||
@@ -70,7 +70,7 @@ const handleRegister = async () => {
|
||||
Selamat Datang
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Sistem Rejimen Askar Jurutera Diraja (SUTERA)
|
||||
Sistem Informasi Koperasi Online (MyKOPKB) 1.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<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 { PasswordInput } from '@/components/ui/password-input'
|
||||
import {
|
||||
getForgotPasswordErrorMessage,
|
||||
getResetPasswordErrorMessage,
|
||||
requestForgotPassword,
|
||||
resetPassword,
|
||||
} from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const otp = ref('')
|
||||
const password = ref('')
|
||||
const passwordConfirmation = 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
|
||||
&& password.value.length > 0
|
||||
&& passwordConfirmation.value.length > 0,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const queryEmail = route.query.email
|
||||
if (typeof queryEmail === 'string' && queryEmail) {
|
||||
email.value = queryEmail
|
||||
}
|
||||
})
|
||||
|
||||
const handleReset = async () => {
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await resetPassword({
|
||||
email: email.value,
|
||||
otp: otp.value,
|
||||
password: password.value,
|
||||
password_confirmation: passwordConfirmation.value,
|
||||
})
|
||||
|
||||
await router.push({
|
||||
name: 'login',
|
||||
query: { reset: 'success', message: response.message },
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = getResetPasswordErrorMessage(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 requestForgotPassword({ email: email.value })
|
||||
resendMessage.value = response.message
|
||||
} catch (error) {
|
||||
errorMessage.value = getForgotPasswordErrorMessage(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)
|
||||
}
|
||||
</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">
|
||||
<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">
|
||||
Tetapkan Semula Kata Laluan
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Masukkan kod OTP dan kata laluan baharu 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">
|
||||
Reset Kata Laluan
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm opacity-70 xl:text-left">
|
||||
Masukkan kod OTP 6 digit dan kata laluan baharu anda.
|
||||
</p>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</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="handleReset">
|
||||
<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"
|
||||
/>
|
||||
<PasswordInput
|
||||
v-model="password"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Kata laluan baharu"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<PasswordInput
|
||||
v-model="passwordConfirmation"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Sahkan kata laluan baharu"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
|
||||
<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 ? 'Menyimpan...' : 'Tetapkan Semula Kata Laluan' }}
|
||||
</Button>
|
||||
<Button
|
||||
class="box mt-4 w-full px-4 py-5"
|
||||
look="outline"
|
||||
type="button"
|
||||
:disabled="resendLoading || !email"
|
||||
@click="handleResend"
|
||||
>
|
||||
{{ resendLoading ? 'Menghantar...' : 'Hantar Semula Kod OTP' }}
|
||||
</Button>
|
||||
<Button
|
||||
class="box mt-4 w-full px-4 py-5"
|
||||
look="outline"
|
||||
type="button"
|
||||
@click="router.push({ name: 'login' })"
|
||||
>
|
||||
Kembali ke Log Masuk
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,6 +19,18 @@ export const authPublicRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('./pages/VerifyEmail.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('./pages/ForgotPassword.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('./pages/ResetPassword.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/account-pending',
|
||||
name: 'account-pending',
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import type {
|
||||
ForgotPasswordPayload,
|
||||
ForgotPasswordResponse,
|
||||
LoginCredentials,
|
||||
LoginResponse,
|
||||
LoginVerificationRequiredData,
|
||||
RegisterCredentials,
|
||||
RegisterResponse,
|
||||
ResendVerificationResponse,
|
||||
ResetPasswordPayload,
|
||||
ResetPasswordResponse,
|
||||
SessionResponse,
|
||||
SwitchRoleResponse,
|
||||
VerifyEmailPayload,
|
||||
@@ -33,6 +37,20 @@ export async function resendVerificationEmail(email: string): Promise<ResendVeri
|
||||
return data
|
||||
}
|
||||
|
||||
export async function requestForgotPassword(
|
||||
payload: ForgotPasswordPayload,
|
||||
): Promise<ForgotPasswordResponse> {
|
||||
const { data } = await api.post<ForgotPasswordResponse>('/forgot-password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetPassword(
|
||||
payload: ResetPasswordPayload,
|
||||
): Promise<ResetPasswordResponse> {
|
||||
const { data } = await api.post<ResetPasswordResponse>('/reset-password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(): Promise<SessionResponse> {
|
||||
const { data } = await api.get<SessionResponse>('/v1/me')
|
||||
return data
|
||||
@@ -59,6 +77,14 @@ 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.')
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -35,7 +35,14 @@ export interface AuthUser {
|
||||
position: string | null
|
||||
phone_number: string | null
|
||||
image_url: string | null
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
status: string
|
||||
gender: string | null
|
||||
marriage_status: string | null
|
||||
join_date: string | null
|
||||
birth_date: string | null
|
||||
birth_place: string | null
|
||||
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
|
||||
}
|
||||
|
||||
@@ -91,3 +98,24 @@ export interface ResendVerificationResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ForgotPasswordPayload {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ForgotPasswordResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ResetPasswordPayload {
|
||||
email: string
|
||||
otp: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
}
|
||||
|
||||
export interface ResetPasswordResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user