first init

This commit is contained in:
ISMAIL MASSERAN
2026-06-08 11:37:14 +08:00
commit 94ecbe5887
1058 changed files with 87732 additions and 0 deletions
+28
View File
@@ -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'
+14
View File
@@ -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>
+131
View File
@@ -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>
+128
View File
@@ -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>
+138
View File
@@ -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>
&amp;
<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>
+164
View File
@@ -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>
+40
View File
@@ -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)
}
+93
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
export { profileLayoutRoutes } from './routes'
export { profileMenu } from './menu'
+9
View File
@@ -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>
+22
View File
@@ -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,
}
}
+2
View File
@@ -0,0 +1,2 @@
export { userLayoutRoutes } from './routes'
export { userMenu } from './menu'
+9
View File
@@ -0,0 +1,9 @@
import type { Menu } from '@/core/types/menu'
export const userMenu: Menu[] = [
{
icon: 'Users',
route_name: 'list-users',
title: 'Senarai Pengguna',
},
]
+111
View File
@@ -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>
+10
View File
@@ -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
}
+26
View File
@@ -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
}