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
@@ -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
}