first init
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { resolvePostLoginRoute } from '@/modules/auth'
|
||||
import { leaveImpersonation, takeImpersonation } from '../services/impersonate.service'
|
||||
import type { UserListItem } from '../types/user.types'
|
||||
|
||||
const IMPERSONATE_PERMISSION = 'menyamar pengguna'
|
||||
const DEVELOPER_ROLE = 'DEVELOPER'
|
||||
|
||||
function userCanImpersonate(): boolean {
|
||||
const authStore = useAuthStore()
|
||||
const roles = authStore.user?.roles ?? []
|
||||
|
||||
if (roles.some((role) => role.name === DEVELOPER_ROLE)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const activeRole = authStore.activeRole
|
||||
const roleWithPermissions = roles.find((role) => role.id === activeRole?.id)
|
||||
|
||||
return (
|
||||
roleWithPermissions?.permissions?.some(
|
||||
(permission) => permission.name === IMPERSONATE_PERMISSION,
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
function targetCanBeImpersonated(target: UserListItem, currentUserId?: string): boolean {
|
||||
if (!currentUserId || target.id === currentUserId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !target.roles?.some((role) => role.name === DEVELOPER_ROLE)
|
||||
}
|
||||
|
||||
export function useImpersonate() {
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
const impersonating = computed(() => authStore.isImpersonating)
|
||||
const canImpersonate = computed(() => userCanImpersonate())
|
||||
|
||||
function canImpersonateUser(target: UserListItem): boolean {
|
||||
return canImpersonate.value && targetCanBeImpersonated(target, authStore.user?.id)
|
||||
}
|
||||
|
||||
async function refreshImpersonationStatus() {
|
||||
await authStore.refreshImpersonationStatus()
|
||||
}
|
||||
|
||||
async function impersonateUser(target: UserListItem) {
|
||||
if (!canImpersonateUser(target) || loading.value || impersonating.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: 'Menyamar pengguna?',
|
||||
text: `Anda akan log masuk sebagai "${target.name}".`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Ya, menyamar',
|
||||
cancelButtonText: 'Batal',
|
||||
reverseButtons: true,
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await takeImpersonation(target.id)
|
||||
authStore.applySession(response)
|
||||
authStore.isImpersonating = true
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: response.message,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
timer: 2000,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(response.redirect_path))
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Gagal menyamar',
|
||||
text: getApiErrorMessage(error, 'Tidak dapat menyamar sebagai pengguna ini.'),
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopImpersonation() {
|
||||
if (!impersonating.value || loading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await leaveImpersonation()
|
||||
authStore.applySession(response)
|
||||
authStore.isImpersonating = false
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: response.message,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
timer: 2000,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(response.redirect_path))
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Gagal tamatkan penyamaran',
|
||||
text: getApiErrorMessage(error, 'Tidak dapat kembali ke akaun asal.'),
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canImpersonate,
|
||||
canImpersonateUser,
|
||||
impersonating,
|
||||
loading,
|
||||
refreshImpersonationStatus,
|
||||
impersonateUser,
|
||||
stopImpersonation,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUsers } from '../services/user.service'
|
||||
import type { UserListItem } from '../types/user.types'
|
||||
|
||||
export function useUserList() {
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchUsers(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listUsers({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'name',
|
||||
sort_order: activeSort?.order ?? 'asc',
|
||||
search: search.value.trim() || undefined,
|
||||
status: statusFilter.value.trim() || undefined,
|
||||
})
|
||||
|
||||
users.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchUsers(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchUsers(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchUsers(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchUsers(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchUsers(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers(1)
|
||||
})
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { userLayoutRoutes } from './routes'
|
||||
export { userMenu } from './menu'
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const userMenu: Menu[] = [
|
||||
{
|
||||
icon: 'Users',
|
||||
route_name: 'list-users',
|
||||
title: 'Senarai Pengguna',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { Search, HatGlasses } from '@lucide/vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useImpersonate } from '../composables/useImpersonate'
|
||||
import { useUserList } from '../composables/useUserList'
|
||||
import type { UserRole, UserListItem } from '../types/user.types'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
|
||||
function formatUserRoles(roles: UserRole[] | undefined): string {
|
||||
return roles?.map((role) => role.name).join(', ') || '-'
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: string) {
|
||||
if (status === 'active') return 'success'
|
||||
if (status === 'inactive') return 'danger'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
{ title: 'Name', key: 'name', sortable: true },
|
||||
{ title: 'Emel', key: 'email', sortable: true },
|
||||
{ title: 'Jawatan', key: 'position', sortable: true },
|
||||
{
|
||||
title: 'Peranan',
|
||||
key: 'roles',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatUserRoles(item.roles),
|
||||
},
|
||||
{ title: 'Status Pengguna', key: 'status', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
} = useUserList()
|
||||
|
||||
const {
|
||||
canImpersonateUser,
|
||||
impersonating,
|
||||
loading: impersonateLoading,
|
||||
refreshImpersonationStatus,
|
||||
impersonateUser,
|
||||
} = useImpersonate()
|
||||
|
||||
onMounted(() => {
|
||||
refreshImpersonationStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable :headers="headers" :items="users" :loading="loading" :pagination="pagination" :current-sort="sortBy"
|
||||
show-pagination exportable export-file-name="users" v-model:page="page" v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate">
|
||||
<template #toolbar>
|
||||
<div class="relative w-full max-w-md">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true" />
|
||||
<Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..." class="w-full pl-9"
|
||||
aria-label="Search users" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.name="{ item }">
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.email="{ item }">
|
||||
<span class="lowercase">{{ item.email }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.roles="{ item }">
|
||||
{{ formatUserRoles(item.roles) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize">
|
||||
{{ item.status }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<Button v-if="canImpersonateUser(item as UserListItem)" type="button" variant="outline" size="sm"
|
||||
class="gap-1.5 bg-orange-500 text-white" :disabled="impersonateLoading || impersonating"
|
||||
:title="impersonating ? 'Anda sedang menyamar pengguna' : 'Menyamar sebagai pengguna'"
|
||||
@click="impersonateUser(item as UserListItem)">
|
||||
<HatGlasses class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const userLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'list-users',
|
||||
name: 'list-users',
|
||||
component: () => import('./pages/UserList.vue'),
|
||||
meta: { title: 'List Users', module: 'user' },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
ImpersonateLeaveResponse,
|
||||
ImpersonateStatusResponse,
|
||||
ImpersonateTakeResponse,
|
||||
} from '../types/impersonate.types'
|
||||
|
||||
export async function takeImpersonation(userId: string): Promise<ImpersonateTakeResponse> {
|
||||
const { data } = await api.get<ImpersonateTakeResponse>(`/v1/impersonate/take/${userId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function leaveImpersonation(): Promise<ImpersonateLeaveResponse> {
|
||||
const { data } = await api.get<ImpersonateLeaveResponse>('/v1/impersonate/leave')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchImpersonationStatus(): Promise<ImpersonateStatusResponse> {
|
||||
const { data } = await api.get<ImpersonateStatusResponse>('/v1/impersonate/status')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type { ListUsersParams, UserListItem } from '../types/user.types'
|
||||
|
||||
export async function listUsers(
|
||||
params: ListUsersParams,
|
||||
): Promise<PaginatedApiResponse<UserListItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<UserListItem>>('/v1/users', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load users')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AuthRole, AuthUser } from '@/modules/auth/types/auth.types'
|
||||
|
||||
export interface ImpersonatedUserSummary {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ImpersonateSessionPayload {
|
||||
data: AuthUser
|
||||
active_role: AuthRole | null
|
||||
can_switch_role: boolean
|
||||
redirect_path: string
|
||||
}
|
||||
|
||||
export interface ImpersonateTakeResponse extends ImpersonateSessionPayload {
|
||||
success: boolean
|
||||
message: string
|
||||
impersonated_user: ImpersonatedUserSummary
|
||||
}
|
||||
|
||||
export interface ImpersonateLeaveResponse extends ImpersonateSessionPayload {
|
||||
success: boolean
|
||||
message: string
|
||||
original_user: ImpersonatedUserSummary
|
||||
}
|
||||
|
||||
export interface ImpersonateStatusResponse {
|
||||
is_impersonating: boolean
|
||||
impersonated_user?: ImpersonatedUserSummary | null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface UserRole {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
position: string
|
||||
phone_number: string
|
||||
image_url: string | null
|
||||
status: string
|
||||
roles: UserRole[]
|
||||
}
|
||||
|
||||
export interface ListUsersParams {
|
||||
page: number
|
||||
per_page: number
|
||||
sort_by: string
|
||||
sort_order: string
|
||||
search?: string
|
||||
status?: string
|
||||
}
|
||||
Reference in New Issue
Block a user