first init
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
export { authPublicRoutes } from './routes'
|
||||
export { authMenu } from './menu'
|
||||
export {
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
verifyEmail,
|
||||
resendVerificationEmail,
|
||||
fetchCurrentUser,
|
||||
getAuthErrorMessage,
|
||||
getRegisterErrorMessage,
|
||||
getVerifyEmailErrorMessage,
|
||||
resolvePostLoginRoute,
|
||||
resolvePostAuthRoute,
|
||||
isAccountPending,
|
||||
isLoginVerificationRequired,
|
||||
} from './services/auth.service'
|
||||
export type {
|
||||
LoginCredentials,
|
||||
LoginResponse,
|
||||
RegisterCredentials,
|
||||
RegisterResponse,
|
||||
VerifyEmailPayload,
|
||||
VerifyEmailResponse,
|
||||
SessionResponse,
|
||||
AuthUser,
|
||||
AuthRole,
|
||||
} from './types/auth.types'
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const authMenu: Menu[] = [
|
||||
{
|
||||
icon: 'CircleGauge',
|
||||
route_name: 'login',
|
||||
title: 'Login',
|
||||
},
|
||||
{
|
||||
icon: 'CircleGauge',
|
||||
route_name: 'register',
|
||||
title: 'Register',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { logout, resolvePostAuthRoute } from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import logoUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loggingOut = ref(false)
|
||||
const checkingStatus = ref(false)
|
||||
|
||||
let statusPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const checkActivationStatus = async () => {
|
||||
checkingStatus.value = true
|
||||
|
||||
try {
|
||||
await authStore.fetchSession()
|
||||
|
||||
if (authStore.isAccountActive) {
|
||||
await router.replace(resolvePostAuthRoute(authStore.user))
|
||||
}
|
||||
} finally {
|
||||
checkingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
loggingOut.value = true
|
||||
|
||||
try {
|
||||
await logout()
|
||||
authStore.clearSession()
|
||||
await router.push({ name: 'login' })
|
||||
} finally {
|
||||
loggingOut.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
statusPollInterval = setInterval(checkActivationStatus, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (statusPollInterval) {
|
||||
clearInterval(statusPollInterval)
|
||||
}
|
||||
})
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative min-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 min-h-screen',
|
||||
'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>
|
||||
</div>
|
||||
|
||||
<div class="my-10 flex min-h-screen py-5 xl:my-0 xl:min-h-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">
|
||||
Akaun Menunggu Pengaktifan
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm opacity-70 xl:text-left">
|
||||
Halo{{ authStore.userName ? `, ${authStore.userName}` : '' }}.
|
||||
</p>
|
||||
|
||||
<AlertRoot class="mt-8" variant="primary">
|
||||
<AlertTitle>E-mel disahkan</AlertTitle>
|
||||
<AlertDescription>
|
||||
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' }}
|
||||
</Button>
|
||||
<Button class="box w-full px-4 py-5" look="outline" type="button" :disabled="loggingOut"
|
||||
@click="handleLogout">
|
||||
{{ loggingOut ? 'Logging out...' : 'Log Keluar' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
<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 { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { PasswordInput } from '@/components/ui/password-input'
|
||||
import {
|
||||
getAuthErrorMessage,
|
||||
login,
|
||||
resolvePostAuthRoute,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const remember = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await login({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
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))
|
||||
} catch (error) {
|
||||
errorMessage.value = getAuthErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<!-- BEGIN: Login Info -->
|
||||
<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">
|
||||
Selamat Datang
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Sistem Rejimen Askar Jurutera Diraja (SUTERA)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: Login Info -->
|
||||
<!-- BEGIN: Login Form -->
|
||||
<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">Log Masuk</h2>
|
||||
<div class="mt-2 text-center opacity-70 xl:hidden">
|
||||
Selamat Datang
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Login failed</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleLogin">
|
||||
<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 />
|
||||
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Password" autocomplete="current-password" required />
|
||||
<div class="flex text-xs sm:text-sm">
|
||||
<div class="mr-auto flex-row items-center">
|
||||
<CheckboxRoot :checked="remember" @checked-change="({ checked }) => (remember = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Ingat saya</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
<a class="opacity-70" href="">Lupa Password?</a>
|
||||
</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">
|
||||
{{ loading ? 'Log masuk...' : 'Log masuk' }}
|
||||
</Button>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
@click="router.push({ name: 'register' })">
|
||||
Daftar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Login Form -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts" setup>
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/illustration.svg'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'pengenalan',
|
||||
title: '1. Pengenalan',
|
||||
placeholder: 'Selamat datang ke SUTERA 3.0. Kami amat menghargai kepercayaan yang anda berikan untuk mengendalikan maklumat peribadi anda. Dasar Privasi ini digubal untuk membantu anda memahami bagaimana kami mengumpul, menggunakan, mendedahkan, dan melindungi data peribadi anda selaras dengan Akta Perlindungan Data Peribadi 2010 (PDPA) dan piawaian keselamatan global.\n\nDasar ini terpakai kepada semua TDM, dan mana-mana pihak yang mengakses atau menggunakan perkhidmatan, laman web, dan aplikasi kami.'
|
||||
},
|
||||
{
|
||||
id: 'data-dikumpul',
|
||||
title: '2. Data yang Dikumpul',
|
||||
placeholder:
|
||||
'Kami mengumpul beberapa jenis maklumat termasuk:\n- Maklumat Pengenalan Peribadi: Nama penuh, alamat e-mel, dan nombor telefon.\n- Data Teknikal: Alamat IP, jenis pelayar, dan log aktiviti sistem.\n- Data Transaksi: Sejarah pembelian dan maklumat langganan perkhidmatan.'
|
||||
},
|
||||
{
|
||||
id: 'tujuan-penggunaan',
|
||||
title: '3. Tujuan Penggunaan Data',
|
||||
placeholder: 'Data anda digunakan untuk:\n- Operasi Sistem: Menguruskan akaun dan menyediakan perkhidmatan.\n- Pengesahan: Menjamin keselamatan akses pengguna.\n- Komunikasi: Menghantar notis pentadbiran dan sokongan pelanggan.\n- Analitik: Menambah baik prestasi dan kualiti platform.'
|
||||
},
|
||||
{
|
||||
id: 'perkongsian-data',
|
||||
title: '4. Perkongsian & Pendedahan Data',
|
||||
placeholder: 'Kami tidak menjual data anda. Pendedahan hanya dilakukan kepada:\n- Pembekal Perkhidmatan: Rakan kongsi teknologi untuk pengehosan dan pembayaran.\n- Pematuhan Undang-undang: Jika dikehendaki oleh pihak berkuasa atau perintah mahkamah.'
|
||||
},
|
||||
{
|
||||
id: 'keselamatan',
|
||||
title: '5. Keselamatan Data',
|
||||
placeholder: 'Kami melaksanakan langkah keselamatan teknikal seperti:\n- Penyulitan (Encryption): Menggunakan SSL/TLS untuk perlindungan data.\n- Kawalan Akses: Menghadkan akses data kepada kakitangan yang dibenarkan sahaja.\n- Audit Berkala: Menjalankan semakan keselamatan sistem secara berterusan.'
|
||||
},
|
||||
{
|
||||
id: 'hak-pengguna',
|
||||
title: '6. Hak Pengguna',
|
||||
placeholder: 'Anda mempunyai hak untuk:\n- Akses & Pembetulan: Melihat dan mengemas kini maklumat peribadi anda.\n- Pemadaman: Meminta pemadaman akaun atau data dalam keadaan tertentu.\n- Sekatan: Mengehadkan pemprosesan data anda.'
|
||||
},
|
||||
{
|
||||
id: 'penyimpanan',
|
||||
title: '7. Tempoh Penyimpanan',
|
||||
placeholder: 'Data disimpan hanya selama diperlukan untuk tujuan perniagaan atau undang-undang. Selepas tempoh tersebut, data akan dipadamkan sepenuhnya atau dianonimkan.'
|
||||
},
|
||||
{
|
||||
id: 'kuki',
|
||||
title: '8. Kuki & Teknologi Penjejakan',
|
||||
placeholder: 'Kami menggunakan kuki(cookies) dan token sesi untuk mengingati tetapan anda dan meningkatkan pengalaman pengguna. Anda boleh menguruskan pilihan kuki melalui tetapan pelayar anda.'
|
||||
},
|
||||
{
|
||||
id: 'perubahan',
|
||||
title: '9. Perubahan Dasar',
|
||||
placeholder: 'Dasar ini mungkin dikemas kini dari semasa ke semasa. Sebarang perubahan besar akan dimaklumkan melalui emel atau notis di platform kami sebelum tarikh kuat kuasa baru.'
|
||||
},
|
||||
{
|
||||
|
||||
id: 'hubungi',
|
||||
title: '10. Hubungi Kami',
|
||||
placeholder:
|
||||
'Jika anda ada pertanyaan, komen atau permintaan tentang dasar ini, hubungi kami di\n- KAPT MOHAMMAD EFANDY BIN JAFFARI (effandy.jaffari@army.mil.my)'
|
||||
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative min-h-screen 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 py-10">
|
||||
<div class="mb-8 flex items-center justify-between gap-4">
|
||||
<RouterLink class="flex items-center gap-3" to="/login">
|
||||
<img class="w-6" :src="logoUrl" :alt="`${appName} logo`" />
|
||||
<span class="text-base font-medium text-white">
|
||||
{{ appName }} {{ appVersion }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<Box raised="double" class="px-6 py-8 sm:px-10">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Dasar Privasi
|
||||
</h1>
|
||||
<p class="text-sm opacity-80">
|
||||
Dikemas kini terakhir: <span class="font-medium">2 Jun 2026</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-8 lg:grid-cols-[260px_1fr]">
|
||||
<aside class="lg:sticky lg:top-6 lg:self-start">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">
|
||||
Isi Kandungan
|
||||
</div>
|
||||
<ul class="mt-3 flex flex-col gap-2">
|
||||
<li v-for="s in sections" :key="s.id">
|
||||
<a class="text-sm opacity-90 hover:opacity-100 hover:underline" :href="`#${s.id}`">
|
||||
{{ s.title }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="flex flex-col gap-8">
|
||||
<section v-for="s in sections" :key="s.id" :id="s.id" class="scroll-mt-6">
|
||||
<h2 class="text-lg font-semibold sm:text-xl">
|
||||
{{ s.title }}
|
||||
</h2>
|
||||
<p class="mt-2 whitespace-pre-line leading-relaxed opacity-80">
|
||||
{{ s.placeholder }}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
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 illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const icNumber = ref('')
|
||||
const password = ref('')
|
||||
const passwordConfirmation = ref('')
|
||||
const termsAccepted = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!termsAccepted.value) {
|
||||
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await register({
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
ic_number: icNumber.value,
|
||||
password: password.value,
|
||||
password_confirmation: passwordConfirmation.value,
|
||||
})
|
||||
|
||||
await router.push({
|
||||
name: 'verify-email',
|
||||
query: { email: response.data.email },
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = getRegisterErrorMessage(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">
|
||||
Selamat Datang
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Sistem Rejimen Askar Jurutera Diraja (SUTERA)
|
||||
</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">Daftar Akaun</h2>
|
||||
<div class="mt-2 text-center opacity-70 xl:hidden">
|
||||
Daftar Akaun
|
||||
</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 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"
|
||||
placeholder="No. Kad Pengenalan" required />
|
||||
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md" type="password"
|
||||
placeholder="Password" 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 Password" 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>
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" setup>
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'pengenalan',
|
||||
title: '1. Pengenalan',
|
||||
placeholder: `
|
||||
Terma dan Syarat ini mengawal penggunaan platform, laman web, dan perkhidmatan yang disediakan oleh Kementerian Pertahanan Malaysia (MINDEF). Dengan mengakses atau menggunakan perkhidmatan kami, anda dianggap telah membaca, memahami, dan bersetuju untuk terikat dengan terma ini. Jika anda tidak bersetuju dengan mana-mana bahagian terma ini, sila hentikan penggunaan perkhidmatan kami.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'akaun',
|
||||
title: '2. Akaun & Kelayakan',
|
||||
placeholder: `
|
||||
Anda mesti berumur sekurang-kurangnya 18 tahun dan merupakan tentera. Anda bertanggungjawab memastikan maklumat akaun sentiasa tepat dan terkini. Anda juga bertanggungjawab menjaga kerahsiaan kata laluan serta segala aktiviti yang berlaku di bawah akaun anda.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'penggunaan',
|
||||
title: '3. Penggunaan Yang Dibenarkan',
|
||||
placeholder: `
|
||||
Anda bersetuju menggunakan perkhidmatan hanya untuk tujuan yang sah dan mematuhi semua undang-undang yang berkuat kuasa. Anda tidak dibenarkan menyalahgunakan sistem, memuat naik kandungan yang menyalahi undang-undang, menyebarkan perisian berbahaya, atau mengganggu operasi platform. Sebarang pelanggaran boleh menyebabkan akaun anda digantung atau ditamatkan.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'pembayaran',
|
||||
title: '4. Pembayaran & Caj (jika berkenaan)',
|
||||
placeholder: `
|
||||
Sesetengah perkhidmatan mungkin tertakluk kepada bayaran yang dinyatakan semasa proses langganan atau pembelian. Semua bayaran hendaklah dibuat melalui kaedah pembayaran yang diterima oleh platform. Bayaran yang telah dibuat tidak akan dikembalikan kecuali dinyatakan sebaliknya. Harga boleh berubah dari semasa ke semasa dengan notis yang munasabah.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'kandungan',
|
||||
title: '5. Kandungan & Hak Milik',
|
||||
placeholder: `
|
||||
Semua kandungan, reka bentuk, logo, teks, grafik, dan bahan lain yang terdapat pada platform ini adalah hak milik Kementerian Pertahanan Malaysia (MINDEF) atau pemberi lesennya. Anda diberikan lesen terhad untuk menggunakan kandungan tersebut bagi tujuan penggunaan peribadi dan bukan komersial sahaja. Sebarang penyalinan, pengubahsuaian, atau pengedaran tanpa kebenaran bertulis adalah dilarang.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'had-liabiliti',
|
||||
title: '6. Had Liabiliti',
|
||||
placeholder: `
|
||||
Perkhidmatan disediakan atas dasar "seadanya" dan "sebagaimana tersedia". Kami tidak menjamin bahawa perkhidmatan akan sentiasa bebas daripada gangguan atau ralat. Setakat yang dibenarkan oleh undang-undang, kami tidak bertanggungjawab terhadap sebarang kerugian langsung, tidak langsung, sampingan, atau berbangkit akibat penggunaan atau ketidakupayaan menggunakan perkhidmatan.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'penamatan',
|
||||
title: '7. Penamatan',
|
||||
placeholder: `
|
||||
Kami berhak menggantung atau menamatkan akses anda kepada perkhidmatan pada bila-bila masa sekiranya berlaku pelanggaran terma ini atau atas sebab keselamatan. Selepas penamatan, hak anda untuk menggunakan perkhidmatan akan tamat serta-merta dan akses kepada data tertentu mungkin tidak lagi tersedia.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'perubahan',
|
||||
title: '8. Perubahan Terma',
|
||||
placeholder: `
|
||||
Kami boleh mengemas kini atau meminda Terma dan Syarat ini dari semasa ke semasa. Sebarang perubahan penting akan dimaklumkan melalui laman web, aplikasi, atau e-mel berdaftar anda. Penggunaan berterusan perkhidmatan selepas tarikh kuat kuasa perubahan dianggap sebagai penerimaan terhadap terma yang dikemas kini.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'hubungi',
|
||||
title: '9. Hubungi Kami',
|
||||
placeholder: `
|
||||
Sekiranya anda mempunyai sebarang pertanyaan berkaitan Terma dan Syarat ini, sila hubungi:
|
||||
|
||||
Nama: KAPT MOHAMMAD EFANDY BIN JAFFARI
|
||||
E-mel: effandy.jaffari@army.mil.my
|
||||
`,
|
||||
},
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative min-h-screen 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%] before: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 py-10">
|
||||
<div class="mb-8 flex items-center justify-between gap-4">
|
||||
<RouterLink class="flex items-center gap-3" to="/login">
|
||||
<img class="w-6" :src="logoUrl" :alt="`${appName} logo`" />
|
||||
<span class="text-base font-medium text-white">
|
||||
{{ appName }} {{ appVersion }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<Box raised="double" class="px-6 py-8 sm:px-10">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Terma dan Syarat
|
||||
</h1>
|
||||
<p class="text-sm opacity-80">
|
||||
Dikemas kini terakhir: <span class="font-medium">2 Jun 2026</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-8 lg:grid-cols-[260px_1fr]">
|
||||
<aside class="lg:sticky lg:top-6 lg:self-start">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">
|
||||
Isi Kandungan
|
||||
</div>
|
||||
<ul class="mt-3 flex flex-col gap-2">
|
||||
<li v-for="s in sections" :key="s.id">
|
||||
<a class="text-sm opacity-90 hover:opacity-100 hover:underline" :href="`#${s.id}`">
|
||||
{{ s.title }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="flex flex-col gap-8">
|
||||
<section v-for="s in sections" :key="s.id" :id="s.id" class="scroll-mt-6">
|
||||
<h2 class="text-lg font-semibold sm:text-xl">
|
||||
{{ s.title }}
|
||||
</h2>
|
||||
<p class="mt-2 whitespace-pre-line leading-relaxed opacity-80">
|
||||
{{ s.placeholder }}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,164 @@
|
||||
<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-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.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>
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const authPublicRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('./pages/Login.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('./pages/Register.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
name: 'verify-email',
|
||||
component: () => import('./pages/VerifyEmail.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/account-pending',
|
||||
name: 'account-pending',
|
||||
component: () => import('./pages/AccountPending.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
name: 'privacy-policy',
|
||||
component: () => import('./pages/PrivacyPolicy.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/terms',
|
||||
name: 'terms',
|
||||
component: () => import('./pages/TermsConditions.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import type {
|
||||
LoginCredentials,
|
||||
LoginResponse,
|
||||
LoginVerificationRequiredData,
|
||||
RegisterCredentials,
|
||||
RegisterResponse,
|
||||
ResendVerificationResponse,
|
||||
SessionResponse,
|
||||
SwitchRoleResponse,
|
||||
VerifyEmailPayload,
|
||||
VerifyEmailResponse,
|
||||
} from '../types/auth.types'
|
||||
|
||||
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||
const { data } = await api.post<LoginResponse>('/login', credentials)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function register(credentials: RegisterCredentials): Promise<RegisterResponse> {
|
||||
const { data } = await api.post<RegisterResponse>('/register', credentials)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function verifyEmail(payload: VerifyEmailPayload): Promise<VerifyEmailResponse> {
|
||||
const { data } = await api.post<VerifyEmailResponse>('/verify-email', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resendVerificationEmail(email: string): Promise<ResendVerificationResponse> {
|
||||
const { data } = await api.post<ResendVerificationResponse>('/verify-email/resend', { email })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(): Promise<SessionResponse> {
|
||||
const { data } = await api.get<SessionResponse>('/v1/me')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function switchActiveRole(roleId: string): Promise<SwitchRoleResponse> {
|
||||
const { data } = await api.post<SwitchRoleResponse>('/v1/active-role/switch', { role_id: roleId })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/v1/logout')
|
||||
}
|
||||
|
||||
export function getAuthErrorMessage(error: unknown): string {
|
||||
return getApiErrorMessage(error, 'Login failed. Please try again.')
|
||||
}
|
||||
|
||||
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 isAccountPending(user: { status: string } | null | undefined): boolean {
|
||||
return user?.status === 'pending'
|
||||
}
|
||||
|
||||
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 resolvePostAuthRoute(
|
||||
user: { status: string } | null | undefined,
|
||||
redirectPath?: string,
|
||||
) {
|
||||
if (isAccountPending(user)) {
|
||||
return { name: 'account-pending' }
|
||||
}
|
||||
|
||||
const routes: Record<string, { name: string }> = {
|
||||
'/profile': { name: 'profile-overview-2' },
|
||||
}
|
||||
|
||||
return routes[redirectPath ?? ''] ?? { name: 'profile-overview-2' }
|
||||
}
|
||||
|
||||
export function resolvePostLoginRoute(redirectPath: string, user?: { status: string } | null) {
|
||||
return resolvePostAuthRoute(user, redirectPath)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export interface SessionResponse {
|
||||
success: boolean
|
||||
data: AuthUser
|
||||
active_role: AuthRole | null
|
||||
can_switch_role: boolean
|
||||
redirect_path: string
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string
|
||||
password: string
|
||||
remember?: boolean
|
||||
}
|
||||
|
||||
export interface AuthRole {
|
||||
id: string
|
||||
name: string
|
||||
fullname: string | null
|
||||
guard_name: string
|
||||
context: string
|
||||
}
|
||||
|
||||
export interface AuthPermission {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
route_name: string | null
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string | null
|
||||
position: string | null
|
||||
phone_number: string | null
|
||||
image_url: string | null
|
||||
status: string
|
||||
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
|
||||
}
|
||||
|
||||
export interface LoginSessionData {
|
||||
user: AuthUser
|
||||
token_type: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface LoginVerificationRequiredData {
|
||||
email: string
|
||||
requires_email_verification: true
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data: LoginSessionData | LoginVerificationRequiredData
|
||||
active_role?: AuthRole | null
|
||||
can_switch_role?: boolean
|
||||
redirect_path?: string
|
||||
}
|
||||
|
||||
export interface SwitchRoleResponse extends SessionResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface RegisterCredentials {
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data: {
|
||||
email: string
|
||||
requires_email_verification: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifyEmailPayload {
|
||||
email: string
|
||||
otp: string
|
||||
}
|
||||
|
||||
export type VerifyEmailResponse = LoginResponse
|
||||
|
||||
export interface ResendVerificationResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { profileLayoutRoutes } from './routes'
|
||||
export { profileMenu } from './menu'
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const profileMenu: Menu[] = [
|
||||
{
|
||||
icon: 'User',
|
||||
route_name: 'profile-overview-2',
|
||||
title: 'Profil',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { PasswordInput } from '@/components/ui/password-input'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { updatePassword } from '@/modules/profile/services/profile.service'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.current_password = ''
|
||||
form.password = ''
|
||||
form.password_confirmation = ''
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (form.password !== form.password_confirmation) {
|
||||
await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Kata laluan tidak sepadan',
|
||||
text: 'Kata laluan baharu dan pengesahan mesti sama.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const res = await updatePassword({
|
||||
current_password: form.current_password,
|
||||
password: form.password,
|
||||
password_confirmation: form.password_confirmation,
|
||||
})
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menukar kata laluan.')
|
||||
}
|
||||
|
||||
resetForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: res.message || 'Kata laluan berjaya dikemas kini.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menukar kata laluan.'),
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!embedded" class="mb-6 flex items-center">
|
||||
<h2 class="mr-auto text-lg font-medium">Tukar Kata Laluan</h2>
|
||||
</div>
|
||||
<Box raised="single" :class="embedded ? 'p-0' : 'p-0 lg:mt-5'">
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="flex items-center border-b border-foreground/15 p-5">
|
||||
<h3 class="mr-auto text-base font-medium">Tukar Kata Laluan</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<FieldGroup>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel for="password-current">Kata Laluan Semasa</FieldLabel>
|
||||
<PasswordInput
|
||||
id="password-current"
|
||||
v-model="form.current_password"
|
||||
placeholder="Kata laluan semasa"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="password-new">Kata Laluan Baharu</FieldLabel>
|
||||
<PasswordInput
|
||||
id="password-new"
|
||||
v-model="form.password"
|
||||
placeholder="Kata laluan baharu"
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="password-confirm">Sahkan Kata Laluan Baharu</FieldLabel>
|
||||
<PasswordInput
|
||||
id="password-confirm"
|
||||
v-model="form.password_confirmation"
|
||||
placeholder="Sahkan kata laluan baharu"
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Field orientation="horizontal">
|
||||
<Button type="submit" variant="primary" :disabled="saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Tukar Kata Laluan' }}
|
||||
</Button>
|
||||
<Button look="outline" type="button" :disabled="saving" @click="resetForm">
|
||||
Set Semula
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,650 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import fakers from '@/utils/faker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { SwitchRoot, SwitchControl } from '@/components/ui/switch'
|
||||
import { ProgressRoot, ProgressTrack, ProgressRange } from '@/components/ui/progress-linear'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { FileIcon } from '@/components/ui/file-icon'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { updateProfile, uploadProfileImage } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ChangePassword from './ChangePassword.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const uploadingImage = ref(false)
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null)
|
||||
const imagePreviewUrl = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
})
|
||||
|
||||
const displayValue = (value: string | null | undefined) => value?.trim() || '-'
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const status = authStore.user?.status
|
||||
if (!status) return '-'
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
})
|
||||
|
||||
const avatarSrc = computed(
|
||||
() => imagePreviewUrl.value ?? authStore.userImageUrl ?? undefined,
|
||||
)
|
||||
|
||||
function syncFormFromUser() {
|
||||
const user = authStore.user
|
||||
if (!user) return
|
||||
|
||||
form.name = user.name ?? ''
|
||||
form.ic_number = user.ic_number ?? ''
|
||||
form.position = user.position ?? ''
|
||||
form.phone_number = user.phone_number ?? ''
|
||||
}
|
||||
|
||||
function clearImagePreview() {
|
||||
if (imagePreviewUrl.value) {
|
||||
URL.revokeObjectURL(imagePreviewUrl.value)
|
||||
imagePreviewUrl.value = null
|
||||
}
|
||||
if (imageInputRef.value) {
|
||||
imageInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openImagePicker() {
|
||||
if (uploadingImage.value) return
|
||||
imageInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onImageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
clearImagePreview()
|
||||
imagePreviewUrl.value = URL.createObjectURL(file)
|
||||
uploadingImage.value = true
|
||||
|
||||
try {
|
||||
const res = await uploadProfileImage(file)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal mengemas kini gambar profil.')
|
||||
}
|
||||
|
||||
authStore.setUserProfile(res.data)
|
||||
clearImagePreview()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: res.message || 'Gambar profil berjaya dikemas kini.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
clearImagePreview()
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal mengemas kini gambar profil.'),
|
||||
})
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveProfile() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const res = await updateProfile({
|
||||
name: form.name.trim(),
|
||||
ic_number: form.ic_number.trim(),
|
||||
position: form.position.trim(),
|
||||
phone_number: form.phone_number.trim(),
|
||||
})
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal mengemas kini profil.')
|
||||
}
|
||||
|
||||
authStore.setUserProfile(res.data)
|
||||
syncFormFromUser()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: res.message || 'Profil berjaya dikemas kini.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal mengemas kini profil.'),
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => authStore.user, syncFormFromUser, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.user) {
|
||||
await authStore.fetchSession()
|
||||
}
|
||||
syncFormFromUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<h2 class="mr-auto text-lg font-medium">Profil</h2>
|
||||
</div>
|
||||
<TabsRoot defaultValue="1">
|
||||
<!-- BEGIN: Profile Info -->
|
||||
<Box raised="single" class="mt-5 p-0">
|
||||
<div class="flex flex-col border-b border-foreground/15 p-5 lg:flex-row">
|
||||
<div class="flex flex-1 items-center justify-center px-5 lg:justify-start">
|
||||
<div class="relative" :class="{ 'opacity-60': uploadingImage }">
|
||||
<AvatarRoot class="size-20 border-5 bg-background rounded-full sm:size-24 lg:size-32">
|
||||
<AvatarFallback>{{ authStore.userName }}</AvatarFallback>
|
||||
<AvatarImage v-if="avatarSrc" :src="avatarSrc" :alt="authStore.userName" />
|
||||
</AvatarRoot>
|
||||
<button type="button"
|
||||
class="bg-(--color)/70 border-3 border-background absolute bottom-0 right-0 mb-1 mr-1 flex cursor-pointer items-center justify-center rounded-full p-2 text-white [--color:var(--color-primary)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="uploadingImage" aria-label="Tukar gambar profil" @click="openImagePicker">
|
||||
<Lucide class="size-4" :icon="uploadingImage ? 'LoaderCircle' : 'Camera'"
|
||||
:class="{ 'animate-spin': uploadingImage }" />
|
||||
</button>
|
||||
<input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/jpg,image/gif" class="hidden"
|
||||
@change="onImageSelected" />
|
||||
</div>
|
||||
<div class="ml-5">
|
||||
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
|
||||
{{ authStore.userName || '-' }}
|
||||
</div>
|
||||
<div class="opacity-70">{{ authStore.userActiveRole }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex-1 border-t border-l border-r border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-t-0 lg:pt-0">
|
||||
<div class="text-center font-medium lg:mt-3 lg:text-left">Maklumat Hubungan</div>
|
||||
<div class="mt-4 flex flex-col items-center justify-center lg:items-start">
|
||||
<div class="flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="Mail" />
|
||||
{{ displayValue(authStore.user?.email) }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="Phone" />
|
||||
{{ displayValue(authStore.user?.phone_number) }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="IdCard" />
|
||||
{{ displayValue(authStore.user?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-0 lg:pt-0">
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium">{{ displayValue(authStore.activeRole?.name) }}</div>
|
||||
<div class="opacity-70">Peranan</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium">{{ statusLabel }}</div>
|
||||
<div class="opacity-70">Status</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium capitalize">{{ displayValue(authStore.activeRole?.context) }}</div>
|
||||
<div class="opacity-70">Konteks</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs title -->
|
||||
<div class="px-5 py-4">
|
||||
<TabsList class="w-full mb-0">
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="1">
|
||||
<Lucide class="mr-2 size-4" icon="User" /> Profil
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="3">
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Maklumat Bank
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="2">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Kata Laluan
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="4">
|
||||
<Lucide class="mr-2 size-4" icon="Server" /> Perkhidmatan
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- Profile Info -->
|
||||
<TabsContent value="1" class="mt-8">
|
||||
<Box raised="single" class="p-6">
|
||||
<form class="space-y-6" @submit.prevent="onSaveProfile">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Maklumat Peribadi</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Kemas kini maklumat peribadi anda.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" :disabled="saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="profile-name">Nama</FieldLabel>
|
||||
<Input id="profile-name" v-model="form.name" type="text" placeholder="Nama penuh" required />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-email">E-mel</FieldLabel>
|
||||
<Input id="profile-email" :model-value="authStore.user?.email ?? ''" type="email" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-ic">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="profile-ic" v-model="form.ic_number" type="text" placeholder="No. kad pengenalan" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-phone">No. Telefon</FieldLabel>
|
||||
<Input id="profile-phone" v-model="form.phone_number" type="text" placeholder="No. telefon" />
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="profile-position">Jawatan</FieldLabel>
|
||||
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</Box>
|
||||
</TabsContent>
|
||||
<TabsContent value="2" class="mt-8">
|
||||
<ChangePassword embedded />
|
||||
</TabsContent>
|
||||
<!-- Maklumat Bank -->
|
||||
<TabsContent value="4" class="mt-8">
|
||||
<div class="grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<!-- BEGIN: Latest Uploads -->
|
||||
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-5 sm:py-3">
|
||||
<h2 class="mr-auto text-base font-medium">Latest Uploads</h2>
|
||||
<MenuRoot class="w-auto ml-auto sm:hidden">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="all">All Files</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
<Button variant="ghost" class="shadow-none border border-foreground/15 hidden sm:flex">All Files</Button>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<FileIcon class="w-12" variant="directory" />
|
||||
<div class="ml-4">
|
||||
<a class="font-medium" href="">Documentation</a>
|
||||
<div class="mt-0.5 text-xs opacity-70">40 KB</div>
|
||||
</div>
|
||||
<MenuRoot class="w-auto ml-auto">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="share">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Share File
|
||||
</MenuItem>
|
||||
<MenuItem value="delete">
|
||||
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center">
|
||||
<FileIcon class="w-12 text-xs" variant="file" type="MP3" />
|
||||
<div class="ml-4">
|
||||
<a class="font-medium" href="">Celine Dion - Ashes</a>
|
||||
<div class="mt-0.5 text-xs opacity-70">40 KB</div>
|
||||
</div>
|
||||
<MenuRoot class="w-auto ml-auto">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="share">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Share File
|
||||
</MenuItem>
|
||||
<MenuItem value="delete">
|
||||
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center">
|
||||
<FileIcon class="w-12" variant="empty-directory" />
|
||||
<div class="ml-4">
|
||||
<a class="font-medium" href="">Resources</a>
|
||||
<div class="mt-0.5 text-xs opacity-70">0 KB</div>
|
||||
</div>
|
||||
<MenuRoot class="w-auto ml-auto">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="share">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Share File
|
||||
</MenuItem>
|
||||
<MenuItem value="delete">
|
||||
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Latest Uploads -->
|
||||
<!-- BEGIN: Work In Progress -->
|
||||
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
|
||||
<TabsRoot defaultValue="wip-0">
|
||||
<div class="relative flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Work In Progress</h2>
|
||||
<MenuRoot class="w-auto ml-auto sm:hidden">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="new">New</MenuItem>
|
||||
<MenuItem value="last-week">Last Week</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
<TabsList class="absolute inset-y-0 h-[2.8rem] right-5 my-auto hidden w-auto sm:flex">
|
||||
<TabsTrigger value="wip-0">New</TabsTrigger>
|
||||
<TabsTrigger value="wip-1">Last Week</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="wip-0" class="p-5">
|
||||
<div>
|
||||
<div class="flex">
|
||||
<div class="mr-auto">Pending Tasks</div>
|
||||
<div>20%</div>
|
||||
</div>
|
||||
<ProgressRoot :defaultValue="50" class="mt-2">
|
||||
<ProgressTrack>
|
||||
<ProgressRange />
|
||||
</ProgressTrack>
|
||||
</ProgressRoot>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex">
|
||||
<div class="mr-auto">Completed Tasks</div>
|
||||
<div>2 / 20</div>
|
||||
</div>
|
||||
<ProgressRoot :defaultValue="25" class="mt-2">
|
||||
<ProgressTrack>
|
||||
<ProgressRange />
|
||||
</ProgressTrack>
|
||||
</ProgressRoot>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex">
|
||||
<div class="mr-auto">Tasks In Progress</div>
|
||||
<div>42</div>
|
||||
</div>
|
||||
<ProgressRoot :defaultValue="75" class="mt-2">
|
||||
<ProgressTrack>
|
||||
<ProgressRange />
|
||||
</ProgressTrack>
|
||||
</ProgressRoot>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<Button variant="ghost" class="border border-foreground/15 shadow-none mx-auto mt-5 inline-block"
|
||||
as="a" href="">
|
||||
View More Details
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</Box>
|
||||
<!-- END: Work In Progress -->
|
||||
<!-- BEGIN: Daily Sales -->
|
||||
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-5 sm:py-3">
|
||||
<h2 class="mr-auto text-base font-medium">Daily Sales</h2>
|
||||
<MenuRoot class="w-auto ml-auto sm:hidden">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="download">
|
||||
<Lucide class="mr-2 size-4" icon="File" /> Download Excel
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
<Button variant="ghost" class="shadow-none border border-foreground/15 hidden sm:flex">
|
||||
<Lucide class="mr-2 size-4" icon="File" /> Download Excel
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-for="(faker, index) in fakers.slice(0, 3)" :key="index" :class="{ 'mt-5': index > 0 }"
|
||||
class="relative flex items-center">
|
||||
<AvatarRoot class="size-12 bg-background rounded-full">
|
||||
<AvatarFallback>IM</AvatarFallback>
|
||||
<AvatarImage :src="faker['photos'][0]" />
|
||||
</AvatarRoot>
|
||||
<div class="ml-4 mr-auto">
|
||||
<a class="font-medium" href="">{{ faker['users'][0]!['name'] }}</a>
|
||||
<div class="mr-5 opacity-70 sm:mr-5">
|
||||
{{
|
||||
index === 0
|
||||
? 'Bootstrap 4 HTML Admin Template'
|
||||
: index === 1
|
||||
? 'Tailwind Admin Dashboard Template'
|
||||
: 'Vuejs HTML Admin Template'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-medium">
|
||||
{{ index === 0 ? '+$19' : index === 1 ? '+$25' : '+$21' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Daily Sales -->
|
||||
<!-- BEGIN: Latest Tasks -->
|
||||
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
|
||||
<TabsRoot defaultValue="lt-0">
|
||||
<div class="relative flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Latest Tasks</h2>
|
||||
<MenuRoot class="w-auto ml-auto sm:hidden">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="new">New</MenuItem>
|
||||
<MenuItem value="last-week">Last Week</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
<TabsList class="absolute inset-y-0 h-[2.8rem] right-5 my-auto hidden w-auto sm:flex">
|
||||
<TabsTrigger value="lt-0">New</TabsTrigger>
|
||||
<TabsTrigger value="lt-1">Last Week</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<TabsContent value="lt-0">
|
||||
<div class="flex items-center">
|
||||
<div class="border-l-4 border-primary/20 pl-4">
|
||||
<a class="font-medium" href="">Create New Campaign</a>
|
||||
<div class="opacity-70">10:00 AM</div>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<SwitchRoot>
|
||||
<SwitchControl />
|
||||
</SwitchRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center">
|
||||
<div class="border-l-4 border-primary/20 pl-4">
|
||||
<a class="font-medium" href="">Meeting With Client</a>
|
||||
<div class="opacity-70">02:00 PM</div>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<SwitchRoot>
|
||||
<SwitchControl />
|
||||
</SwitchRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center">
|
||||
<div class="border-l-4 border-primary/20 pl-4">
|
||||
<a class="font-medium" href="">Create New Repository</a>
|
||||
<div class="opacity-70">04:00 PM</div>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<SwitchRoot>
|
||||
<SwitchControl />
|
||||
</SwitchRoot>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</Box>
|
||||
<!-- END: Latest Tasks -->
|
||||
<Box raised="single" class="col-span-12 p-0">
|
||||
<CarouselRoot :default-page="0" :slide-count="fakers.slice(0, 5).length">
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-3">
|
||||
<h2 class="mr-auto text-base font-medium">New Products</h2>
|
||||
<CarouselPrevTrigger as-child>
|
||||
<Button variant="ghost" class="shadow-none border border-foreground/15 mr-2">
|
||||
<Lucide class="size-4" icon="ChevronLeft" />
|
||||
</Button>
|
||||
</CarouselPrevTrigger>
|
||||
<CarouselNextTrigger as-child>
|
||||
<Button variant="ghost" class="shadow-none border border-foreground/15">
|
||||
<Lucide class="size-4" icon="ChevronRight" />
|
||||
</Button>
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
<div class="px-5">
|
||||
<CarouselItemGroup class="py-5">
|
||||
<CarouselItem v-for="(faker, index) in fakers.slice(0, 5)" :key="index" :index="index" class="px-5"
|
||||
as-child>
|
||||
<div>
|
||||
<div class="flex flex-col items-center pb-5 lg:flex-row">
|
||||
<div class="flex flex-col items-center pr-5 sm:flex-row lg:border-r border-foreground/15">
|
||||
<div class="sm:mr-5">
|
||||
<AvatarRoot class="size-20 bg-background rounded-full">
|
||||
<AvatarFallback>IM</AvatarFallback>
|
||||
<AvatarImage :src="faker['images'][0]" />
|
||||
</AvatarRoot>
|
||||
</div>
|
||||
<div class="mr-auto mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<a class="text-lg font-medium" href="">
|
||||
{{ faker['products'][0]!['name'] }}
|
||||
</a>
|
||||
<div class="mt-1 opacity-70 sm:mt-0">
|
||||
{{ faker['news'][0]!['shortContent'] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex w-full flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-4 lg:mt-0 lg:w-auto lg:border-t-0 lg:pt-0">
|
||||
<div class="w-20 rounded-md py-3 text-center">
|
||||
<div class="text-xl font-medium">{{ faker['totals'][0] }}</div>
|
||||
<div class="opacity-70">Orders</div>
|
||||
</div>
|
||||
<div class="w-20 rounded-md py-3 text-center">
|
||||
<div class="text-xl font-medium">{{ faker['totals'][1] }}k</div>
|
||||
<div class="opacity-70">Purchases</div>
|
||||
</div>
|
||||
<div class="w-20 rounded-md py-3 text-center">
|
||||
<div class="text-xl font-medium">{{ faker['totals'][0] }}</div>
|
||||
<div class="opacity-70">Reviews</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center border-t border-foreground/15 pt-5 sm:flex-row">
|
||||
<div
|
||||
class="flex w-full items-center justify-center border-b border-foreground/15 pb-5 sm:w-auto sm:justify-start sm:border-b-0 sm:pb-0">
|
||||
<Badge look="outline" class="mr-3 px-3 py-2">{{
|
||||
faker['dates'][0]
|
||||
}}</Badge>
|
||||
<div class="opacity-70">Date of Release</div>
|
||||
</div>
|
||||
<div class="mt-5 flex sm:ml-auto sm:mt-0">
|
||||
<Button variant="ghost"
|
||||
class="border border-foreground/15 shadow-none ml-auto">Preview</Button>
|
||||
<Button variant="ghost" class="border border-foreground/15 shadow-none ml-2">Details</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</div>
|
||||
</CarouselRoot>
|
||||
</Box>
|
||||
<!-- END: New Products -->
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,353 @@
|
||||
<script lang="ts" setup>
|
||||
import fakers from '@/utils/faker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuPositioner,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
} from '@/components/ui/menu'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
TooltipPositioner,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<h2 class="mr-auto text-lg font-medium">Update Profile</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<!-- BEGIN: Profile Menu -->
|
||||
<div class="col-span-12 flex flex-col-reverse lg:col-span-4 lg:block 2xl:col-span-3">
|
||||
<Box class="mt-5 p-0">
|
||||
<div class="relative flex items-center p-5">
|
||||
<AvatarRoot class="size-12 rounded-full">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="fakers[0]!['photos'][0]" alt="avatar" />
|
||||
</AvatarRoot>
|
||||
<div class="ml-4 mr-auto">
|
||||
<div class="text-base font-medium">
|
||||
{{ fakers[0]!['users'][0]!['name'] }}
|
||||
</div>
|
||||
<div class="opacity-70">{{ fakers[0]!['jobs'][0] }}</div>
|
||||
</div>
|
||||
<MenuRoot
|
||||
class="w-auto"
|
||||
:positioning="{
|
||||
placement: 'bottom',
|
||||
}"
|
||||
>
|
||||
<MenuTrigger as-child>
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-64">
|
||||
<div class="font-medium">Export Options</div>
|
||||
<MenuSeparator />
|
||||
<MenuItem value="0">
|
||||
<Lucide icon="Activity" />
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem value="1">
|
||||
<Lucide icon="Box" />
|
||||
Indonesia
|
||||
<Badge variant="danger" look="outline" class="ml-auto">10</Badge>
|
||||
</MenuItem>
|
||||
<MenuItem value="2">
|
||||
<Lucide icon="Layout" />
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem value="3">
|
||||
<Lucide icon="Sidebar" />
|
||||
Indonesia
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<div class="flex">
|
||||
<Button class="text-xs" type="button" variant="primary" look="outline" size="sm">
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
class="ml-auto text-xs"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
look="outline"
|
||||
size="sm"
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
</div>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 border-t border-foreground/10 p-5">
|
||||
<a
|
||||
class="[&.active]:text-primary active flex items-center [&.active]:font-medium"
|
||||
href=""
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Activity" /> Personal Information
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Box" /> Account Settings
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Change Password
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Settings" /> User Settings
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 border-t border-foreground/10 p-5">
|
||||
<a class="flex items-center" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Activity" /> Email Settings
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Box" /> Saved Credit Cards
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Social Networks
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Settings" /> Tax Information
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex border-t border-foreground/10 p-5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="shadow-none border border-foreground/15"
|
||||
type="button"
|
||||
>
|
||||
New Group
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="shadow-none border border-foreground/15 ml-auto"
|
||||
type="button"
|
||||
>
|
||||
New Quick Link
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Profile Menu -->
|
||||
<div class="col-span-12 lg:col-span-8 2xl:col-span-9">
|
||||
<!-- BEGIN: Display Information -->
|
||||
<Box class="p-0 lg:mt-5">
|
||||
<div class="flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Display Information</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex flex-col xl:flex-row">
|
||||
<div class="mt-6 flex-1 xl:mt-0">
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-12 gap-x-5">
|
||||
<div class="col-span-12 2xl:col-span-6">
|
||||
<Field>
|
||||
<FieldLabel for="update-profile-form-1">Display Name</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-1"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['name']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-2">Nearest MRT Station</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-2">
|
||||
<NativeSelectOption value="1">Admiralty</NativeSelectOption>
|
||||
<NativeSelectOption value="2">Aljunied</NativeSelectOption>
|
||||
<NativeSelectOption value="3">Ang Mo Kio</NativeSelectOption>
|
||||
<NativeSelectOption value="4">Bartley</NativeSelectOption>
|
||||
<NativeSelectOption value="5">Beauty World</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12 2xl:col-span-6">
|
||||
<Field class="mt-3 2xl:mt-0">
|
||||
<FieldLabel for="update-profile-form-3">Postal Code</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-3">
|
||||
<NativeSelectOption value="1"
|
||||
>018906 - 1 STRAITS BOULEVARD SINGA...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="2"
|
||||
>018910 - 5A MARINA GARDENS DRIVE...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="3"
|
||||
>018915 - 100A CENTRAL BOULEVARD...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="4"
|
||||
>018925 - 21 PARK STREET MARINA...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="5"
|
||||
>018926 - 23 PARK STREET MARINA...</NativeSelectOption
|
||||
>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-4">Phone Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-4"
|
||||
type="text"
|
||||
value="65570828"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12">
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-5">Address</FieldLabel>
|
||||
<Textarea
|
||||
id="update-profile-form-5"
|
||||
value="10 Anson Road, International Plaza, #10-11, 079903 Singapore, Singapore"
|
||||
placeholder="Adress"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="w-28" type="button" variant="primary"> Save </Button>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<div class="mx-auto w-52 xl:ml-6 xl:mr-0">
|
||||
<div class="rounded-xl border-2 border-dashed border-foreground/15 p-5">
|
||||
<div class="image-fit relative mx-auto h-40 cursor-pointer">
|
||||
<img
|
||||
class="rounded-xl"
|
||||
:src="fakers[0]!['photos'][0]"
|
||||
alt="Midone - Tailwind Admin Dashboard Template"
|
||||
/>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="bg-(--color)/80 border-(--color) text-medium absolute right-0 top-0 -mr-2 -mt-2 flex size-5 items-center justify-center rounded-full text-white [--color:var(--color-danger)]"
|
||||
>
|
||||
<Lucide class="h-4 w-4" icon="X" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPositioner>
|
||||
<TooltipContent>Remove this profile photo?</TooltipContent>
|
||||
</TooltipPositioner>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<div class="relative mx-auto mt-3 cursor-pointer">
|
||||
<Button class="w-full" type="button" variant="primary"> Change Photo </Button>
|
||||
<Input class="absolute left-0 top-0 h-full w-full opacity-0" type="file" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Display Information -->
|
||||
<!-- BEGIN: Personal Information -->
|
||||
<Box class="mt-8 p-0">
|
||||
<div class="flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Personal Information</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-12 gap-x-5">
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<Field>
|
||||
<FieldLabel for="update-profile-form-6">Email</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-6"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['email']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-7">Name</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-7"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['name']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-8">ID Type</FieldLabel>
|
||||
<NativeSelect id="update-profile-form-8">
|
||||
<NativeSelectOption>IC</NativeSelectOption>
|
||||
<NativeSelectOption>FIN</NativeSelectOption>
|
||||
<NativeSelectOption>Passport</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-9">ID Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-9"
|
||||
type="text"
|
||||
value="357821204950001"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<Field class="mt-3 xl:mt-0">
|
||||
<FieldLabel for="update-profile-form-10">Phone Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-10"
|
||||
type="text"
|
||||
value="65570828"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-11">Address</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-11"
|
||||
type="text"
|
||||
value="10 Anson Road, International Plaza, #10-11, 079903 Singapore, Singapore"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-12">Bank Name</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-12">
|
||||
<NativeSelectOption value="1">SBI - STATE BANK OF INDIA</NativeSelectOption>
|
||||
<NativeSelectOption value="2">CITI BANK - CITI BANK</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-13">Bank Account</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-13"
|
||||
type="text"
|
||||
value="DBS Current 011-903573-0"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button class="mr-auto w-28" type="button" variant="primary"> Save </Button>
|
||||
<Button type="button" variant="danger" look="outline">
|
||||
<Lucide class="mr-1 size-4" icon="Trash" /> Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Personal Information -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const profileLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'profile-overview-2',
|
||||
name: 'profile-overview-2',
|
||||
component: () => import('./pages/ProfileOverview2.vue'),
|
||||
meta: { title: 'Profil', module: 'profile' },
|
||||
},
|
||||
{
|
||||
path: 'update-profile',
|
||||
name: 'update-profile',
|
||||
component: () => import('./pages/UpdateProfile.vue'),
|
||||
meta: { title: 'Update Profile', module: 'profile' },
|
||||
},
|
||||
{
|
||||
path: 'change-password',
|
||||
name: 'change-password',
|
||||
component: () => import('./pages/ChangePassword.vue'),
|
||||
meta: { title: 'Change Password', module: 'profile' },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
UpdatePasswordPayload,
|
||||
UpdatePasswordResponse,
|
||||
UpdateProfilePayload,
|
||||
UpdateProfileResponse,
|
||||
} from '../types/profile.types'
|
||||
|
||||
function appendIfDefined(formData: FormData, key: string, value: string | undefined) {
|
||||
if (value !== undefined && value !== '') {
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadProfileImage(image: File): Promise<UpdateProfileResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', image)
|
||||
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateProfile(payload: UpdateProfilePayload): Promise<UpdateProfileResponse> {
|
||||
const hasImage = payload.image instanceof File
|
||||
|
||||
if (hasImage) {
|
||||
const formData = new FormData()
|
||||
formData.append('name', payload.name!.trim())
|
||||
appendIfDefined(formData, 'ic_number', payload.ic_number)
|
||||
appendIfDefined(formData, 'position', payload.position)
|
||||
appendIfDefined(formData, 'phone_number', payload.phone_number)
|
||||
formData.append('image', payload.image!)
|
||||
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {
|
||||
name: payload.name!.trim(),
|
||||
}
|
||||
|
||||
if (payload.ic_number?.trim()) body.ic_number = payload.ic_number.trim()
|
||||
if (payload.position?.trim()) body.position = payload.position.trim()
|
||||
if (payload.phone_number?.trim()) body.phone_number = payload.phone_number.trim()
|
||||
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updatePassword(payload: UpdatePasswordPayload): Promise<UpdatePasswordResponse> {
|
||||
const { data } = await api.put<UpdatePasswordResponse>('/v1/profile/password', payload)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AuthUser } from '@/modules/auth/types/auth.types'
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
name?: string
|
||||
ic_number?: string
|
||||
position?: string
|
||||
phone_number?: string
|
||||
image?: File
|
||||
}
|
||||
|
||||
export interface UpdateProfileResponse {
|
||||
success: boolean
|
||||
data: AuthUser
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UpdatePasswordPayload {
|
||||
current_password: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
}
|
||||
|
||||
export interface UpdatePasswordResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { resolvePostLoginRoute } from '@/modules/auth'
|
||||
import { leaveImpersonation, takeImpersonation } from '../services/impersonate.service'
|
||||
import type { UserListItem } from '../types/user.types'
|
||||
|
||||
const IMPERSONATE_PERMISSION = 'menyamar pengguna'
|
||||
const DEVELOPER_ROLE = 'DEVELOPER'
|
||||
|
||||
function userCanImpersonate(): boolean {
|
||||
const authStore = useAuthStore()
|
||||
const roles = authStore.user?.roles ?? []
|
||||
|
||||
if (roles.some((role) => role.name === DEVELOPER_ROLE)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const activeRole = authStore.activeRole
|
||||
const roleWithPermissions = roles.find((role) => role.id === activeRole?.id)
|
||||
|
||||
return (
|
||||
roleWithPermissions?.permissions?.some(
|
||||
(permission) => permission.name === IMPERSONATE_PERMISSION,
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
function targetCanBeImpersonated(target: UserListItem, currentUserId?: string): boolean {
|
||||
if (!currentUserId || target.id === currentUserId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !target.roles?.some((role) => role.name === DEVELOPER_ROLE)
|
||||
}
|
||||
|
||||
export function useImpersonate() {
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
const impersonating = computed(() => authStore.isImpersonating)
|
||||
const canImpersonate = computed(() => userCanImpersonate())
|
||||
|
||||
function canImpersonateUser(target: UserListItem): boolean {
|
||||
return canImpersonate.value && targetCanBeImpersonated(target, authStore.user?.id)
|
||||
}
|
||||
|
||||
async function refreshImpersonationStatus() {
|
||||
await authStore.refreshImpersonationStatus()
|
||||
}
|
||||
|
||||
async function impersonateUser(target: UserListItem) {
|
||||
if (!canImpersonateUser(target) || loading.value || impersonating.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: 'Menyamar pengguna?',
|
||||
text: `Anda akan log masuk sebagai "${target.name}".`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Ya, menyamar',
|
||||
cancelButtonText: 'Batal',
|
||||
reverseButtons: true,
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await takeImpersonation(target.id)
|
||||
authStore.applySession(response)
|
||||
authStore.isImpersonating = true
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: response.message,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
timer: 2000,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(response.redirect_path))
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Gagal menyamar',
|
||||
text: getApiErrorMessage(error, 'Tidak dapat menyamar sebagai pengguna ini.'),
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopImpersonation() {
|
||||
if (!impersonating.value || loading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await leaveImpersonation()
|
||||
authStore.applySession(response)
|
||||
authStore.isImpersonating = false
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: response.message,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
timer: 2000,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(response.redirect_path))
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Gagal tamatkan penyamaran',
|
||||
text: getApiErrorMessage(error, 'Tidak dapat kembali ke akaun asal.'),
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canImpersonate,
|
||||
canImpersonateUser,
|
||||
impersonating,
|
||||
loading,
|
||||
refreshImpersonationStatus,
|
||||
impersonateUser,
|
||||
stopImpersonation,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUsers } from '../services/user.service'
|
||||
import type { UserListItem } from '../types/user.types'
|
||||
|
||||
export function useUserList() {
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchUsers(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listUsers({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'name',
|
||||
sort_order: activeSort?.order ?? 'asc',
|
||||
search: search.value.trim() || undefined,
|
||||
status: statusFilter.value.trim() || undefined,
|
||||
})
|
||||
|
||||
users.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchUsers(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchUsers(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchUsers(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchUsers(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchUsers(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers(1)
|
||||
})
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { userLayoutRoutes } from './routes'
|
||||
export { userMenu } from './menu'
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const userMenu: Menu[] = [
|
||||
{
|
||||
icon: 'Users',
|
||||
route_name: 'list-users',
|
||||
title: 'Senarai Pengguna',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { Search, HatGlasses } from '@lucide/vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useImpersonate } from '../composables/useImpersonate'
|
||||
import { useUserList } from '../composables/useUserList'
|
||||
import type { UserRole, UserListItem } from '../types/user.types'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
|
||||
function formatUserRoles(roles: UserRole[] | undefined): string {
|
||||
return roles?.map((role) => role.name).join(', ') || '-'
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: string) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'inactive') return 'danger'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
{ title: 'Name', key: 'name', sortable: true },
|
||||
{ title: 'Emel', key: 'email', sortable: true },
|
||||
{ title: 'Jawatan', key: 'position', sortable: true },
|
||||
{
|
||||
title: 'Peranan',
|
||||
key: 'roles',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatUserRoles(item.roles),
|
||||
},
|
||||
{ title: 'Status Pengguna', key: 'status', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
} = useUserList()
|
||||
|
||||
const {
|
||||
canImpersonateUser,
|
||||
impersonating,
|
||||
loading: impersonateLoading,
|
||||
refreshImpersonationStatus,
|
||||
impersonateUser,
|
||||
} = useImpersonate()
|
||||
|
||||
onMounted(() => {
|
||||
refreshImpersonationStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable :headers="headers" :items="users" :loading="loading" :pagination="pagination" :current-sort="sortBy"
|
||||
show-pagination exportable export-file-name="users" v-model:page="page" v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate">
|
||||
<template #toolbar>
|
||||
<div class="relative w-full max-w-md">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true" />
|
||||
<Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..." class="w-full pl-9"
|
||||
aria-label="Search users" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.name="{ item }">
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.email="{ item }">
|
||||
<span class="lowercase">{{ item.email }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.roles="{ item }">
|
||||
{{ formatUserRoles(item.roles) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize">
|
||||
{{ item.status }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<Button v-if="canImpersonateUser(item as UserListItem)" type="button" variant="outline" size="sm"
|
||||
class="gap-1.5 bg-orange-500 text-white" :disabled="impersonateLoading || impersonating"
|
||||
:title="impersonating ? 'Anda sedang menyamar pengguna' : 'Menyamar sebagai pengguna'"
|
||||
@click="impersonateUser(item as UserListItem)">
|
||||
<HatGlasses class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const userLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'list-users',
|
||||
name: 'list-users',
|
||||
component: () => import('./pages/UserList.vue'),
|
||||
meta: { title: 'List Users', module: 'user' },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
ImpersonateLeaveResponse,
|
||||
ImpersonateStatusResponse,
|
||||
ImpersonateTakeResponse,
|
||||
} from '../types/impersonate.types'
|
||||
|
||||
export async function takeImpersonation(userId: string): Promise<ImpersonateTakeResponse> {
|
||||
const { data } = await api.get<ImpersonateTakeResponse>(`/v1/impersonate/take/${userId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function leaveImpersonation(): Promise<ImpersonateLeaveResponse> {
|
||||
const { data } = await api.get<ImpersonateLeaveResponse>('/v1/impersonate/leave')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchImpersonationStatus(): Promise<ImpersonateStatusResponse> {
|
||||
const { data } = await api.get<ImpersonateStatusResponse>('/v1/impersonate/status')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type { ListUsersParams, UserListItem } from '../types/user.types'
|
||||
|
||||
export async function listUsers(
|
||||
params: ListUsersParams,
|
||||
): Promise<PaginatedApiResponse<UserListItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<UserListItem>>('/v1/users', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load users')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AuthRole, AuthUser } from '@/modules/auth/types/auth.types'
|
||||
|
||||
export interface ImpersonatedUserSummary {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ImpersonateSessionPayload {
|
||||
data: AuthUser
|
||||
active_role: AuthRole | null
|
||||
can_switch_role: boolean
|
||||
redirect_path: string
|
||||
}
|
||||
|
||||
export interface ImpersonateTakeResponse extends ImpersonateSessionPayload {
|
||||
success: boolean
|
||||
message: string
|
||||
impersonated_user: ImpersonatedUserSummary
|
||||
}
|
||||
|
||||
export interface ImpersonateLeaveResponse extends ImpersonateSessionPayload {
|
||||
success: boolean
|
||||
message: string
|
||||
original_user: ImpersonatedUserSummary
|
||||
}
|
||||
|
||||
export interface ImpersonateStatusResponse {
|
||||
is_impersonating: boolean
|
||||
impersonated_user?: ImpersonatedUserSummary | null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface UserRole {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
position: string
|
||||
phone_number: string
|
||||
image_url: string | null
|
||||
status: string
|
||||
roles: UserRole[]
|
||||
}
|
||||
|
||||
export interface ListUsersParams {
|
||||
page: number
|
||||
per_page: number
|
||||
sort_by: string
|
||||
sort_order: string
|
||||
search?: string
|
||||
status?: string
|
||||
}
|
||||
Reference in New Issue
Block a user