DONE: layout letter with letterhead and footer, notification for newly...

This commit is contained in:
ISMAIL MASSERAN
2026-06-28 02:16:49 +00:00
parent 94ecbe5887
commit 034ecf947f
400 changed files with 29802 additions and 5446 deletions
+8
View File
@@ -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>
+26 -4
View File
@@ -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">
+1 -1
View File
@@ -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>
+201
View File
@@ -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>
+12
View File
@@ -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'
}
+28
View File
@@ -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
}