update hak akses dan perbaikan api pembuatan tiket
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">
|
||||
<v-icon size="28" color="white">mdi-shield-lock-outline</v-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="page-title">Manajemen Hak Akses (Keycloak Integration)</h2>
|
||||
<p class="page-subtitle">Konfigurasi Akses Halaman berdasarkan Role & Group Real-time</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hak-akses-page pa-4">
|
||||
<!-- Tabs Selection -->
|
||||
<v-tabs v-model="activeTab" color="primary" class="mb-4 bg-white rounded-lg elevation-1" align-tabs="start">
|
||||
<v-tab value="roles" class="text-capitalize">
|
||||
<v-icon start>mdi-account-star-outline</v-icon>
|
||||
Realm Roles
|
||||
</v-tab>
|
||||
<v-tab value="groups" class="text-capitalize">
|
||||
<v-icon start>mdi-account-group-outline</v-icon>
|
||||
Groups
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="action-bar mb-4 d-flex justify-space-between align-center px-4 py-3 bg-white rounded-xl border elevation-1">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="`Cari ${activeTab === 'roles' ? 'Role' : 'Group'}...`"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded="lg"
|
||||
class="max-width-400"
|
||||
></v-text-field>
|
||||
<v-chip color="primary" variant="tonal" class="font-weight-bold">
|
||||
{{ displayItems.length }} {{ activeTab === 'roles' ? 'Roles' : 'Groups' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="loadData"
|
||||
prepend-icon="mdi-sync"
|
||||
variant="flat"
|
||||
rounded="lg"
|
||||
:loading="loading"
|
||||
>
|
||||
Sync Keycloak
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<v-card class="rounded-xl border-0 shadow-soft overflow-hidden">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="displayItems"
|
||||
:loading="loading"
|
||||
class="custom-table"
|
||||
hover
|
||||
>
|
||||
<template v-slot:[`item.no`]="{ index }">
|
||||
<span class="text-grey-darken-1 font-weight-medium">{{ index + 1 }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.name`]="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:icon="activeTab === 'roles' ? 'mdi-account-star' : 'mdi-folder-account'"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="mr-2"
|
||||
></v-icon>
|
||||
<span class="font-weight-bold text-primary-darken-1">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.pagesCount`]="{ item }">
|
||||
<v-tooltip bottom>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip
|
||||
v-bind="props"
|
||||
:color="item.validPagesCount > 0 ? 'success' : 'grey'"
|
||||
size="small"
|
||||
variant="flat"
|
||||
class="font-weight-bold"
|
||||
>
|
||||
{{ item.validPagesCount }} Halaman
|
||||
</v-chip>
|
||||
</template>
|
||||
<span>Akses ke {{ item.validPagesCount }} Halaman Terdaftar</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.memberCount`]="{ item }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="small"
|
||||
class="text-capitalize font-weight-bold"
|
||||
prepend-icon="mdi-account-multiple-outline"
|
||||
@click="viewMembers(item)"
|
||||
>
|
||||
Deteksi Member
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.actions`]="{ item }">
|
||||
<v-btn
|
||||
color="warning"
|
||||
size="small"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-shield-edit-outline"
|
||||
class="text-capitalize rounded-lg mr-2"
|
||||
@click="editPermissions(item)"
|
||||
>
|
||||
Atur Akses
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Edit Permissions Dialog -->
|
||||
<v-dialog v-model="showEditDialog" max-width="900px" persistent scrollable>
|
||||
<v-card class="rounded-xl overflow-hidden">
|
||||
<v-card-title class="pa-6 border-b d-flex align-center bg-primary text-white">
|
||||
<v-icon icon="mdi-shield-edit" class="mr-3"></v-icon>
|
||||
<div>
|
||||
<div class="text-h6">Konfigurasi Akses: {{ editedEntity?.name }}</div>
|
||||
<div class="text-caption text-white text-opacity-75">Tentukan halaman yang dapat diakses oleh {{ activeTab }} ini</div>
|
||||
</div>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon="mdi-close" variant="text" color="white" @click="showEditDialog = false"></v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<div class="pa-6 bg-grey-lighten-4">
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4 rounded-lg"
|
||||
icon="mdi-information-outline"
|
||||
>
|
||||
Jika user memiliki beberapa Role/Group, mereka akan mendapatkan akses gabungan dari semuanya.
|
||||
</v-alert>
|
||||
</div>
|
||||
<div class="pa-6 pt-0">
|
||||
<EditHakAkses
|
||||
v-if="editedEntity"
|
||||
:pages="editedEntity.pages"
|
||||
@update:pages="editedEntity.pages = $event"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pa-6 bg-white">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="outlined" rounded="lg" @click="showEditDialog = false" class="px-6 border-primary-lighten-4 text-primary">Batal</v-btn>
|
||||
<v-btn color="primary" rounded="lg" @click="savePermissions" class="px-6 ml-2" :loading="loading">
|
||||
Simpan Perubahan
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Member List Dialog -->
|
||||
<v-dialog v-model="showMembersDialog" max-width="600px" scrollable>
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title class="pa-6 border-b d-flex align-center bg-grey-lighten-4">
|
||||
<v-icon icon="mdi-account-group" color="primary" class="mr-3"></v-icon>
|
||||
<div>
|
||||
<div class="text-h6">Member: {{ editedEntity?.name }}</div>
|
||||
<div class="text-caption text-grey-darken-1">Daftar pengguna yang memiliki {{ activeTab }} ini</div>
|
||||
</div>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon="mdi-close" variant="text" @click="showMembersDialog = false"></v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-list v-if="!memberLoading && currentMembers.length > 0" lines="two" class="pa-0">
|
||||
<v-list-item
|
||||
v-for="(user, i) in currentMembers"
|
||||
:key="user.id"
|
||||
:border="i !== currentMembers.length - 1"
|
||||
class="px-6 py-3"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar color="primary-lighten-5" size="44">
|
||||
<v-icon icon="mdi-account" color="primary"></v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold text-primary">{{ user.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="mt-1">
|
||||
<v-chip size="x-small" variant="outlined" class="mr-2">{{ user.username }}</v-chip>
|
||||
<span class="text-caption text-grey">{{ user.email }}</span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else-if="!memberLoading" class="pa-12 text-center">
|
||||
<v-avatar color="grey-lighten-4" size="80" class="mb-4">
|
||||
<v-icon icon="mdi-account-off-outline" color="grey" size="40"></v-icon>
|
||||
</v-avatar>
|
||||
<div class="text-grey font-weight-medium">Tidak ada pengguna yang terdeteksi</div>
|
||||
</div>
|
||||
<div v-else class="pa-6">
|
||||
<v-skeleton-loader type="list-item-avatar-two-line@5"></v-skeleton-loader>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="snackbar.timeout" location="top">
|
||||
{{ snackbar.message }}
|
||||
<template v-slot:actions>
|
||||
<v-btn icon="mdi-close" variant="text" @click="snackbar.show = false"></v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
|
||||
import { useNavItemsStore } from '~/stores/navItems1';
|
||||
import type { HakAkses } from '~/types/setting';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
|
||||
const navItemsStore = useNavItemsStore();
|
||||
|
||||
// State
|
||||
const activeTab = ref('roles');
|
||||
const hakAksesList = ref<HakAkses[]>([]);
|
||||
const keycloakEntities = ref<{ roles: any[], groups: any[] }>({ roles: [], groups: [] });
|
||||
const loading = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
const showMembersDialog = ref(false);
|
||||
const editedEntity = ref<any>(null);
|
||||
const currentMembers = ref<any[]>([]);
|
||||
const memberLoading = ref(false);
|
||||
|
||||
const search = ref('');
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success',
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
const showSnackbar = (message: string, color: string = 'success') => {
|
||||
snackbar.value.message = message;
|
||||
snackbar.value.color = color;
|
||||
snackbar.value.show = true;
|
||||
};
|
||||
|
||||
// Headers
|
||||
const headers = [
|
||||
{ title: 'No', key: 'no', align: 'center' as const, width: '80px' },
|
||||
{ title: 'Nama', key: 'name', sortable: true },
|
||||
{ title: 'Akses', key: 'pagesCount', align: 'center' as const, width: '150px' },
|
||||
{ title: 'Member', key: 'memberCount', align: 'center' as const, width: '150px' },
|
||||
{ title: 'Aksi', key: 'actions', align: 'center' as const, sortable: false, width: '250px' }
|
||||
];
|
||||
|
||||
// Load everything
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 1. Load our local mappings
|
||||
const hakAksesRes = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
|
||||
if (hakAksesRes && hakAksesRes.success) {
|
||||
hakAksesList.value = hakAksesRes.data || [];
|
||||
}
|
||||
|
||||
// 2. Load Keycloak entities
|
||||
const entitiesRes = await $fetch<{ success: boolean, data: any }>('/api/hak-akses/entities');
|
||||
if (entitiesRes && entitiesRes.success) {
|
||||
keycloakEntities.value = entitiesRes.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
showSnackbar('Gagal memuat data dari Keycloak', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Computed list based on active tab
|
||||
const displayItems = computed(() => {
|
||||
const list = activeTab.value === 'roles' ? keycloakEntities.value.roles : keycloakEntities.value.groups;
|
||||
|
||||
// Get all valid paths from current navigation store
|
||||
const allNavPaths = new Set<string>();
|
||||
const extractPaths = (items: any[]) => {
|
||||
items.forEach((item: any) => {
|
||||
if (item.path) allNavPaths.add(item.path);
|
||||
if (item.children) extractPaths(item.children);
|
||||
});
|
||||
};
|
||||
extractPaths(navItemsStore.getNavItems);
|
||||
|
||||
return list.map((entity: any) => {
|
||||
// Find existing permission mapping
|
||||
const mapping = hakAksesList.value.find(h => h.namaHakAkses === entity.name);
|
||||
if (!mapping) return { ...entity, pages: [], validPagesCount: 0, status: 'tidak aktif', id_mapping: null };
|
||||
|
||||
// Count only valid pages that exist in navigation
|
||||
const validPages = (mapping.pages || []).filter(p => {
|
||||
const path = typeof p === 'string' ? p : (p as any)?.path;
|
||||
return path && allNavPaths.has(path);
|
||||
});
|
||||
|
||||
return {
|
||||
...entity,
|
||||
pages: mapping.pages, // Keep original for editing
|
||||
validPagesCount: validPages.length,
|
||||
status: mapping.status,
|
||||
id_mapping: mapping.id
|
||||
};
|
||||
}).filter((item: any) => {
|
||||
if (!search.value) return true;
|
||||
return item.name.toLowerCase().includes(search.value.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
const editPermissions = (item: any) => {
|
||||
editedEntity.value = JSON.parse(JSON.stringify(item));
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const viewMembers = async (item: any) => {
|
||||
editedEntity.value = item;
|
||||
currentMembers.value = [];
|
||||
showMembersDialog.value = true;
|
||||
memberLoading.value = true;
|
||||
try {
|
||||
const res = await $fetch<{ success: boolean, data: any[] }>(`/api/hak-akses/members`, {
|
||||
params: {
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
id: item.id
|
||||
}
|
||||
});
|
||||
if (res && res.success) {
|
||||
currentMembers.value = res.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading members:', error);
|
||||
showSnackbar('Gagal memuat member', 'error');
|
||||
} finally {
|
||||
memberLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!editedEntity.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: editedEntity.value.id_mapping, // If null, backend create new
|
||||
namaHakAkses: editedEntity.value.name,
|
||||
status: 'aktif',
|
||||
pages: editedEntity.value.pages
|
||||
};
|
||||
|
||||
const response = await $fetch<{ success: boolean, message: string }>('/api/hak-akses', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
showSnackbar('Hak akses berhasil diperbarui', 'success');
|
||||
await loadData();
|
||||
await navItemsStore.refreshNavItems();
|
||||
showEditDialog.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error);
|
||||
showSnackbar('Gagal menyimpan perubahan', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Colors from Design System
|
||||
$primary-700: #3556AE;
|
||||
$primary-600: #3A61C9;
|
||||
|
||||
$success-700: #1B6E53;
|
||||
$success-600: #009262;
|
||||
$neutral-100: #FFFFFF;
|
||||
$neutral-800: #4D4D4D;
|
||||
|
||||
// Font Family & Weights
|
||||
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
// Apply font family
|
||||
* {
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PAGE HEADER
|
||||
// ============================================
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
||||
border-radius: 0 !important;
|
||||
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 28px;
|
||||
height: 80px;
|
||||
color: $neutral-100;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-right: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin: 0;
|
||||
color: $neutral-100;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 2px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $neutral-100;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ACTION BAR (matching UserLogin)
|
||||
// ============================================
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: $neutral-100;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #E0E0E0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.action-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-chip {
|
||||
color: $primary-600 !important;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.custom-table :deep(th) {
|
||||
background-color: #f5f5f5 !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.max-width-400 {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom: 1px solid rgba(0,0,0,0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
// server/api/hak-akses/entities.get.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log("📥 Fetching Keycloak entities (roles & groups)");
|
||||
|
||||
// Get session from session store
|
||||
const { getSessionFromCookie } = await import('~/server/utils/sessionStore');
|
||||
const session = await getSessionFromCookie(event);
|
||||
|
||||
if (!session) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Session expired or not found",
|
||||
});
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const accessToken = session.accessToken;
|
||||
|
||||
if (!accessToken) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "No access token found in session",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const issuerUrl = new URL(config.keycloakIssuer);
|
||||
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
|
||||
const adminBaseUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}`;
|
||||
|
||||
// 1. Fetch Realm Roles
|
||||
const rolesResponse = await fetch(`${adminBaseUrl}/roles`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!rolesResponse.ok) {
|
||||
throw new Error(`Failed to fetch roles: ${rolesResponse.status}`);
|
||||
}
|
||||
|
||||
const roles = await rolesResponse.json();
|
||||
// Filter out potential client-specific roles if needed, keeping realm roles
|
||||
const realmRoles = roles.filter((r: any) => !r.clientRole);
|
||||
|
||||
// 2. Fetch Groups
|
||||
const groupsResponse = await fetch(`${adminBaseUrl}/groups`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!groupsResponse.ok) {
|
||||
throw new Error(`Failed to fetch groups: ${groupsResponse.status}`);
|
||||
}
|
||||
|
||||
const groups = await groupsResponse.json();
|
||||
|
||||
// 3. For each role and group, we'll need to fetch members to show the count
|
||||
// NOTE: This might be expensive if there are many roles/groups.
|
||||
// For now, let's just return the list and we'll fetch members on demand in the UI or in a separate task.
|
||||
// To be efficient, we'll only return basic info here.
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
roles: realmRoles.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description || '',
|
||||
type: 'role'
|
||||
})),
|
||||
groups: groups.map((g: any) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
path: g.path,
|
||||
type: 'group'
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error fetching Keycloak entities:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || "Failed to fetch Keycloak entities",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// server/api/hak-akses/members.get.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const { type, name, id } = query;
|
||||
|
||||
if (!type || (!name && !id)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Type and (Name or ID) are required",
|
||||
});
|
||||
}
|
||||
|
||||
// Get session
|
||||
const { getSessionFromCookie } = await import('~/server/utils/sessionStore');
|
||||
const session = await getSessionFromCookie(event);
|
||||
|
||||
if (!session) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Session expired",
|
||||
});
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const accessToken = session.accessToken;
|
||||
|
||||
try {
|
||||
const issuerUrl = new URL(config.keycloakIssuer);
|
||||
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
|
||||
const adminBaseUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}`;
|
||||
|
||||
let url = '';
|
||||
if (type === 'role') {
|
||||
url = `${adminBaseUrl}/roles/${name}/users`;
|
||||
} else if (type === 'group') {
|
||||
url = `${adminBaseUrl}/groups/${id}/members`;
|
||||
} else {
|
||||
throw new Error("Invalid type");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch members: ${response.status}`);
|
||||
}
|
||||
|
||||
const members = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: members.map((m: any) => ({
|
||||
id: m.id,
|
||||
username: m.username,
|
||||
email: m.email,
|
||||
firstName: m.firstName,
|
||||
lastName: m.lastName,
|
||||
name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.username
|
||||
}))
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error fetching members:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || "Failed to fetch members",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// composables/useHakAkses.ts
|
||||
// Composable for handling user permissions/access based on hakAkses
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
import type { HakAkses } from "~/types/setting";
|
||||
|
||||
export const useHakAkses = () => {
|
||||
const { user, checkAuth } = useAuth();
|
||||
|
||||
/**
|
||||
* Get all pages that user has access to based on their roles
|
||||
*/
|
||||
const getAllowedPages = async (): Promise<string[]> => {
|
||||
// Ensure user is loaded
|
||||
if (!user.value) {
|
||||
await checkAuth();
|
||||
}
|
||||
|
||||
const currentUser = user.value;
|
||||
|
||||
if (!currentUser) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get roles and groups from multiple possible sources in User object
|
||||
const roles = [
|
||||
...(currentUser.roles || []),
|
||||
...((currentUser as any).realm_access?.roles || []),
|
||||
...((currentUser as any).resource_access?.['web-antrean']?.roles || [])
|
||||
];
|
||||
|
||||
const groups = (currentUser as any).groups || [];
|
||||
|
||||
// Combine everything the user belongs to
|
||||
const entities = [...new Set([...roles, ...groups])];
|
||||
|
||||
if (entities.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch all hak akses data
|
||||
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
|
||||
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
const hakAksesList = response.data;
|
||||
|
||||
// Filter hak akses that match user's entities and are active
|
||||
const userHakAkses = hakAksesList.filter((hakAkses) =>
|
||||
entities.includes(hakAkses.namaHakAkses) &&
|
||||
hakAkses.status === 'aktif'
|
||||
);
|
||||
|
||||
// Combine all pages from all matching hak akses
|
||||
const allPages = userHakAkses.reduce((pages: string[], hakAkses) => {
|
||||
if (hakAkses.pages && Array.isArray(hakAkses.pages)) {
|
||||
return [...pages, ...hakAkses.pages];
|
||||
}
|
||||
return pages;
|
||||
}, []);
|
||||
|
||||
// Remove duplicates
|
||||
return [...new Set(allPages)];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching allowed pages:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has access to a specific page
|
||||
*/
|
||||
const hasPageAccess = async (pagePath: string): Promise<boolean> => {
|
||||
const allowedPages = await getAllowedPages();
|
||||
return allowedPages.includes(pagePath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has access to any page in a list
|
||||
*/
|
||||
const hasAnyPageAccess = async (pagePaths: string[]): Promise<boolean> => {
|
||||
const allowedPages = await getAllowedPages();
|
||||
return pagePaths.some(path => allowedPages.includes(path));
|
||||
};
|
||||
|
||||
const allHakAksesData = ref<HakAkses[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const fetchHakAkses = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
|
||||
if (response && response.success) {
|
||||
allHakAksesData.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching hak akses:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
allHakAksesData,
|
||||
isLoading,
|
||||
fetchHakAkses,
|
||||
getAllowedPages,
|
||||
hasPageAccess,
|
||||
hasAnyPageAccess
|
||||
};
|
||||
};
|
||||
@@ -1,457 +1,110 @@
|
||||
<template>
|
||||
<v-card class="pa-6 rounded-xl elevation-4">
|
||||
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
|
||||
<v-icon icon="mdi-lock-check-outline" class="mr-2 text-primary" size="28"></v-icon>
|
||||
<span>Edit Hak Akses Menu</span>
|
||||
</v-card-title>
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<v-card-text class="px-0">
|
||||
<v-row v-if="localItem.role || localItem.group" class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal" density="compact">
|
||||
<strong>Role:</strong> {{ localItem.role }} | <strong>Group:</strong> {{ localItem.group }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
|
||||
<v-table density="comfortable" class="elevation-1 rounded-xl hak-akses-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1 kol-no">No</th>
|
||||
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-status">Status</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Akses</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Lihat</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Tambah</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Edit</th>
|
||||
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Hapus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="backendPermissions.length > 0">
|
||||
<tr v-for="(perm, index) in sortedPermissions" :key="perm.id" :class="{ 'bg-grey-lighten-5': perm.level === 2 }">
|
||||
<td class="kol-no text-center">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div :style="{ paddingLeft: perm.level === 2 ? '32px' : '0' }" class="d-flex align-center">
|
||||
<v-icon v-if="perm.level === 2" icon="mdi-subdirectory-arrow-right" size="small" class="mr-2 text-grey"></v-icon>
|
||||
<span :class="{ 'font-weight-bold': perm.level === 1 }">{{ perm.pagename }}</span>
|
||||
<v-chip v-if="perm.level" size="x-small" variant="outlined" class="ml-2" color="grey">
|
||||
Level {{ perm.level }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-status">
|
||||
<v-chip
|
||||
:color="isPageMapped(perm.pagename) ? 'success' : 'warning'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ isPageMapped(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.active"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.read"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.create"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.update"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox
|
||||
v-model="perm.delete"
|
||||
hide-details
|
||||
color="primary"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
|
||||
<td class="kol-no text-center">{{ index + 1 }}</td>
|
||||
<td>{{ menu.name }}</td>
|
||||
<td class="text-center kol-status">
|
||||
<v-chip color="success" size="small" variant="tonal">
|
||||
Mapped
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canAccess" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canView" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canAdd" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canEdit" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center kol-aksi">
|
||||
<div class="cek-wrapper">
|
||||
<v-checkbox v-model="menu.canDelete" hide-details color="primary"></v-checkbox>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end pa-0 mt-4">
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="flat"
|
||||
rounded="lg"
|
||||
class="text-capitalize mr-2"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Batal
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
rounded="lg"
|
||||
class="text-capitalize"
|
||||
@click="handleSave"
|
||||
:loading="isSaving"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
<v-card variant="outlined" class="rounded-lg overflow-hidden">
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left font-weight-bold">Halaman</th>
|
||||
<th class="text-center font-weight-bold" style="width: 120px;">Akses</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in flatNavItems" :key="item.path || item.name">
|
||||
<tr :class="{ 'bg-grey-lighten-4': !item.path }">
|
||||
<td :style="{ paddingLeft: item.level * 24 + 'px' }">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :icon="item.icon" size="small" class="mr-2" color="grey"></v-icon>
|
||||
<span :class="{ 'font-weight-bold': !item.path }">{{ item.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<v-checkbox
|
||||
v-if="item.path"
|
||||
:model-value="isAllowed(item.path)"
|
||||
@update:model-value="toggleAccess(item, !!$event)"
|
||||
hide-details
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="d-inline-flex"
|
||||
></v-checkbox>
|
||||
<v-icon v-else icon="mdi-folder-open" size="small" color="grey-lighten-1"></v-icon>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useNavItemsStore } from '~/stores/navItems1';
|
||||
|
||||
// Define types for better readability and safety
|
||||
interface HakAksesMenu {
|
||||
name: string;
|
||||
canAccess: boolean;
|
||||
canView: boolean;
|
||||
canAdd: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
interface BackendPermissionItem {
|
||||
id: number;
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
disable: boolean;
|
||||
delete: boolean;
|
||||
active: boolean;
|
||||
pagename: string;
|
||||
pagesID: number;
|
||||
level?: number;
|
||||
sort?: number;
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface HakAksesData {
|
||||
id: number;
|
||||
role?: string;
|
||||
group?: string;
|
||||
namaTipeUser: string;
|
||||
hakAksesMenu: HakAksesMenu[];
|
||||
}
|
||||
|
||||
// Define props with type validation
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as () => HakAksesData,
|
||||
required: true,
|
||||
},
|
||||
pages: {
|
||||
type: Array as () => any[], // Be flexible with legacy data
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
// Define emits for clarity
|
||||
const emits = defineEmits(['save', 'cancel']);
|
||||
const emit = defineEmits(['update:pages']);
|
||||
|
||||
// Use a local copy to avoid mutating the prop directly
|
||||
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
|
||||
const backendPermissions = ref<BackendPermissionItem[]>([]);
|
||||
const isSaving = ref(false);
|
||||
const navItemsStore = useNavItemsStore();
|
||||
|
||||
// Helper function to normalize group name
|
||||
const normalizeGroup = (group: string): string => {
|
||||
const normalized = group.trim();
|
||||
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM"
|
||||
if (normalized.toLowerCase().includes('instalasi')) {
|
||||
const parts = normalized.split(/\s+/);
|
||||
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
|
||||
if (stimIndex !== -1) {
|
||||
return 'STIM';
|
||||
}
|
||||
}
|
||||
// Jika group adalah "Instalasi STIM", return "STIM"
|
||||
if (normalized.toLowerCase() === 'instalasi stim') {
|
||||
return 'STIM';
|
||||
}
|
||||
return normalized.toUpperCase();
|
||||
};
|
||||
interface FlatNavItem {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Helper function to normalize role name
|
||||
const normalizeRole = (role: string): string => {
|
||||
const normalized = role.toLowerCase().trim();
|
||||
// Mapping khusus untuk role default
|
||||
if (normalized === 'default-roles-sandbox') {
|
||||
return 'superadmin';
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// Fetch permissions from backend API when component mounts
|
||||
onMounted(async () => {
|
||||
if (localItem.value.role && localItem.value.group) {
|
||||
try {
|
||||
// Normalize role and group before making API call
|
||||
const normalizedRole = normalizeRole(localItem.value.role);
|
||||
const normalizedGroup = normalizeGroup(localItem.value.group);
|
||||
|
||||
console.log('🔄 Fetching permissions with normalized values:', {
|
||||
originalRole: localItem.value.role,
|
||||
normalizedRole,
|
||||
originalGroup: localItem.value.group,
|
||||
normalizedGroup,
|
||||
const flatNavItems = computed(() => {
|
||||
const result: FlatNavItem[] = [];
|
||||
|
||||
const walk = (items: any[], level = 0) => {
|
||||
items.forEach(item => {
|
||||
result.push({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
icon: item.icon,
|
||||
level
|
||||
});
|
||||
|
||||
const response = await $fetch<any>('/api/permission', {
|
||||
query: {
|
||||
roles: normalizedRole,
|
||||
groups: normalizedGroup,
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log(`✅ Received ${response.data.length} permissions from API`);
|
||||
// Create a deep copy to avoid mutating the original
|
||||
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
|
||||
...perm,
|
||||
}));
|
||||
} else {
|
||||
console.warn('⚠️ No data received from API or invalid structure');
|
||||
if (item.children && item.children.length > 0) {
|
||||
walk(item.children, level + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching permissions:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Role or group is missing:', {
|
||||
role: localItem.value.role,
|
||||
group: localItem.value.group,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort permissions: level 1 first, then level 2 grouped under their parents
|
||||
const sortedPermissions = computed(() => {
|
||||
if (backendPermissions.value.length === 0) return [];
|
||||
|
||||
const level1 = backendPermissions.value.filter(p => p.level === 1);
|
||||
const level2 = backendPermissions.value.filter(p => p.level === 2);
|
||||
|
||||
// Sort level 1 by sort order
|
||||
level1.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
// Sort level 2 by sort order
|
||||
level2.sort((a, b) => (a.sort || 0) - (b.sort || 0));
|
||||
|
||||
// Build result: insert level 2 items after their parent
|
||||
const result: BackendPermissionItem[] = [];
|
||||
|
||||
level1.forEach(parent => {
|
||||
result.push(parent);
|
||||
// Add children of this parent
|
||||
const children = level2.filter(child => child.parent === parent.pagesID);
|
||||
result.push(...children);
|
||||
});
|
||||
|
||||
// Add any remaining level 2 items that don't have a parent match
|
||||
const addedChildren = new Set(level2.filter(c => c.parent && level1.some(p => p.pagesID === c.parent)).map(c => c.id));
|
||||
const remainingChildren = level2.filter(c => !addedChildren.has(c.id));
|
||||
result.push(...remainingChildren);
|
||||
};
|
||||
|
||||
walk(navItemsStore.getNavItems);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Cek apakah suatu pagename dari backend sudah termapping ke salah satu menu di hakAksesMenu
|
||||
const isPageMapped = (pagename: string): boolean => {
|
||||
if (!localItem.value.hakAksesMenu || localItem.value.hakAksesMenu.length === 0) {
|
||||
const isAllowed = (path: string) => {
|
||||
if (!props.pages || !Array.isArray(props.pages)) return false;
|
||||
|
||||
return props.pages.some(p => {
|
||||
if (typeof p === 'string') return p === path;
|
||||
if (p && typeof p === 'object' && p.path) return p.path === path;
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerPage = (pagename || '').toLowerCase();
|
||||
return localItem.value.hakAksesMenu.some((menu) => {
|
||||
const name = (menu.name || '').toLowerCase();
|
||||
return (
|
||||
name === lowerPage ||
|
||||
name.includes(lowerPage) ||
|
||||
lowerPage.includes(name)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface NavItemOrder {
|
||||
name: string;
|
||||
children?: NavItemOrder[];
|
||||
}
|
||||
|
||||
// Build a map of menu order based on the sidebar configuration so the list is always aligned
|
||||
const menuOrder = computed(() => {
|
||||
const order: Record<string, number> = {};
|
||||
const walk = (items: NavItemOrder[], startIndex = 0): number => {
|
||||
let idx = startIndex;
|
||||
items.forEach((item) => {
|
||||
order[item.name] = idx;
|
||||
idx += 1;
|
||||
if (item.children?.length) {
|
||||
idx = walk(item.children, idx);
|
||||
}
|
||||
});
|
||||
return idx;
|
||||
};
|
||||
walk(navItemsStore.navItems);
|
||||
return order;
|
||||
});
|
||||
|
||||
const orderedMenus = computed(() => {
|
||||
const order = menuOrder.value;
|
||||
return [...localItem.value.hakAksesMenu].sort((a, b) => {
|
||||
const orderA = order[a.name] ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = order[b.name] ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle save - convert backend permissions back to menu structure if needed
|
||||
const handleSave = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
// If we have backend permissions, we need to map them back to the menu structure
|
||||
if (backendPermissions.value.length > 0) {
|
||||
// Update the local item with backend permissions mapped to menu structure
|
||||
const updatedItem = {
|
||||
...localItem.value,
|
||||
backendPermissions: backendPermissions.value,
|
||||
};
|
||||
emits('save', updatedItem);
|
||||
} else {
|
||||
// Use existing menu structure
|
||||
emits('save', localItem.value);
|
||||
const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
|
||||
let newPages = [...props.pages];
|
||||
|
||||
if (allowed) {
|
||||
if (!newPages.includes(item.path)) {
|
||||
newPages.push(item.path);
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
} else {
|
||||
newPages = newPages.filter(p => p !== item.path);
|
||||
}
|
||||
|
||||
emit('update:pages', newPages);
|
||||
};
|
||||
|
||||
// Watch the prop for changes and update the local copy
|
||||
watch(() => props.item, (newItem) => {
|
||||
localItem.value = JSON.parse(JSON.stringify(newItem));
|
||||
// Re-fetch permissions if role/group changed
|
||||
if (newItem.role && newItem.group) {
|
||||
// Normalize role and group before making API call
|
||||
const normalizedRole = normalizeRole(newItem.role);
|
||||
const normalizedGroup = normalizeGroup(newItem.group);
|
||||
|
||||
console.log('🔄 Re-fetching permissions with normalized values:', {
|
||||
originalRole: newItem.role,
|
||||
normalizedRole,
|
||||
originalGroup: newItem.group,
|
||||
normalizedGroup,
|
||||
});
|
||||
|
||||
$fetch<any>('/api/permission', {
|
||||
query: {
|
||||
roles: normalizedRole,
|
||||
groups: normalizedGroup,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
console.log(`✅ Received ${response.data.length} permissions from API`);
|
||||
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
|
||||
...perm,
|
||||
}));
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('❌ Error fetching permissions:', error);
|
||||
});
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-table :deep(th) {
|
||||
font-weight: bold !important;
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.v-table :deep(td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.v-checkbox :deep(.v-selection-control__input) {
|
||||
color: #2196F3 !important;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-no) {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-aksi) {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.hak-akses-table :deep(.kol-status) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.cek-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.bg-grey-lighten-4 {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
// composables/useHakAkses.ts
|
||||
// Composable for handling user permissions/access based on hakAkses
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
import type { HakAkses } from "~/types/setting";
|
||||
|
||||
export const useHakAkses = () => {
|
||||
const { user, checkAuth } = useAuth();
|
||||
|
||||
/**
|
||||
* Get all pages that user has access to based on their roles
|
||||
*/
|
||||
const getAllowedPages = async (): Promise<string[]> => {
|
||||
// Ensure user is loaded
|
||||
if (!user.value) {
|
||||
await checkAuth();
|
||||
}
|
||||
|
||||
const currentUser = user.value;
|
||||
|
||||
if (!currentUser) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get roles and groups from multiple possible sources in User object
|
||||
const roles = [
|
||||
...(currentUser.roles || []),
|
||||
...((currentUser as any).realm_access?.roles || []),
|
||||
...((currentUser as any).resource_access?.['web-antrean']?.roles || [])
|
||||
];
|
||||
|
||||
const groups = (currentUser as any).groups || [];
|
||||
|
||||
// Combine everything the user belongs to: Roles, Groups, and their own Username
|
||||
const entities = [...new Set([
|
||||
(currentUser as any).namaUser, // Individual user mapping support
|
||||
...roles,
|
||||
...groups
|
||||
])].filter(Boolean);
|
||||
|
||||
if (entities.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch all hak akses data
|
||||
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
|
||||
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
const hakAksesList = response.data;
|
||||
|
||||
// Filter hak akses that match user's entities and are active
|
||||
const userHakAkses = hakAksesList.filter((hakAkses) =>
|
||||
entities.includes(hakAkses.namaHakAkses) &&
|
||||
hakAkses.status === 'aktif'
|
||||
);
|
||||
|
||||
// Combine all pages from all matching hak akses
|
||||
const allPages = userHakAkses.reduce((pages: string[], hakAkses) => {
|
||||
if (hakAkses.pages && Array.isArray(hakAkses.pages)) {
|
||||
return [...pages, ...hakAkses.pages];
|
||||
}
|
||||
return pages;
|
||||
}, []);
|
||||
|
||||
// Remove duplicates
|
||||
return [...new Set(allPages)];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching allowed pages:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has access to a specific page
|
||||
*/
|
||||
const hasPageAccess = async (pagePath: string): Promise<boolean> => {
|
||||
const allowedPages = await getAllowedPages();
|
||||
return allowedPages.includes(pagePath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has access to any page in a list
|
||||
*/
|
||||
const hasAnyPageAccess = async (pagePaths: string[]): Promise<boolean> => {
|
||||
const allowedPages = await getAllowedPages();
|
||||
return pagePaths.some(path => allowedPages.includes(path));
|
||||
};
|
||||
|
||||
const allHakAksesData = ref<HakAkses[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const fetchHakAkses = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean, data: HakAkses[] }>('/api/hak-akses');
|
||||
if (response && response.success) {
|
||||
allHakAksesData.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching hak akses:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
allHakAksesData,
|
||||
isLoading,
|
||||
fetchHakAkses,
|
||||
getAllowedPages,
|
||||
hasPageAccess,
|
||||
hasAnyPageAccess
|
||||
};
|
||||
};
|
||||
+16
-211
@@ -20,7 +20,7 @@ import { useNavItemsStore } from '~/stores/navItems1';
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'permissions']
|
||||
middleware: ['auth', 'checkPageAccess']
|
||||
})
|
||||
|
||||
// State for controlling the sidebar
|
||||
@@ -30,221 +30,26 @@ const rail = ref(true);
|
||||
const navItemsStore = useNavItemsStore();
|
||||
const { user, checkAuth } = useAuth();
|
||||
|
||||
interface NavItem {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
// Navigation will be filtered via navItemsStore using the new hakAkses system
|
||||
|
||||
interface BackendPermission {
|
||||
id: number;
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
disable: boolean;
|
||||
delete: boolean;
|
||||
active: boolean;
|
||||
pagename: string;
|
||||
pagesID: number;
|
||||
level?: number;
|
||||
sort?: number;
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface PermissionResponse {
|
||||
message?: string;
|
||||
data?: BackendPermission[];
|
||||
meta?: {
|
||||
count: number;
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Cache for API permissions
|
||||
const apiPermissions = ref<BackendPermission[]>([]);
|
||||
const currentUserRoles = ref<string[]>([]);
|
||||
const currentUserGroups = ref<string[]>([]);
|
||||
|
||||
// Get current user data with roles and groups
|
||||
const fetchCurrentUserData = async () => {
|
||||
try {
|
||||
const userData = await $fetch('/api/users/current');
|
||||
currentUserRoles.value = [
|
||||
...(userData.realmRoles || []),
|
||||
...(userData.roles || []),
|
||||
];
|
||||
|
||||
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
|
||||
const groups: string[] = [];
|
||||
(userData.groups || []).forEach((g: string) => {
|
||||
const parts = g.split('/').filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
groups.push(parts[1]); // Get second part as group name
|
||||
} else if (parts.length === 1) {
|
||||
groups.push(parts[0]);
|
||||
}
|
||||
});
|
||||
currentUserGroups.value = groups;
|
||||
|
||||
return { roles: currentUserRoles.value, groups: currentUserGroups.value };
|
||||
} catch (error) {
|
||||
console.error('Error fetching current user data:', error);
|
||||
return { roles: [], groups: [] };
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch permissions from backend API (only for nav filtering, not for saving)
|
||||
// Saving is now handled by permissions middleware
|
||||
const fetchPermissionsFromAPI = async () => {
|
||||
const { roles, groups } = await fetchCurrentUserData();
|
||||
|
||||
if (roles.length === 0 || groups.length === 0) {
|
||||
console.warn('No roles or groups found for current user');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first role and first group (or combine as needed)
|
||||
const primaryRole = roles[0] || '';
|
||||
const primaryGroup = groups[0] || '';
|
||||
|
||||
if (!primaryRole || !primaryGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch<PermissionResponse>('/api/permission', {
|
||||
query: {
|
||||
roles: primaryRole,
|
||||
groups: primaryGroup,
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.data && Array.isArray(response.data)) {
|
||||
apiPermissions.value = response.data;
|
||||
// Note: Auto-save to allHakAksesData is now handled by permissions middleware
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions from API:', error);
|
||||
// Fallback to local storage if API fails
|
||||
apiPermissions.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const filteredNavItems = computed(() => {
|
||||
// If no API permissions, check local storage as fallback
|
||||
if (apiPermissions.value.length === 0) {
|
||||
const hakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
|
||||
const roleCandidates = [
|
||||
...(user.value?.roles || []),
|
||||
...(user.value?.realm_access?.roles || []),
|
||||
].map((role) => role.toLowerCase());
|
||||
|
||||
const localPermission = hakAksesData.value.find((item) =>
|
||||
roleCandidates.includes(item.role?.toLowerCase() || item.namaTipeUser?.toLowerCase())
|
||||
);
|
||||
|
||||
if (localPermission) {
|
||||
const permissionMap = new Map(
|
||||
localPermission.hakAksesMenu.map((menu: any) => [menu.name.toLowerCase(), menu])
|
||||
);
|
||||
|
||||
const applyFilter = (items: NavItem[]): NavItem[] => {
|
||||
return items
|
||||
.map((item) => {
|
||||
const menuPerm = permissionMap.get(item.name.toLowerCase());
|
||||
const filteredChildren = item.children ? applyFilter(item.children) : [];
|
||||
const allowThis = menuPerm ? (menuPerm as any).canAccess : false;
|
||||
const hasChildren = filteredChildren.length > 0;
|
||||
|
||||
if (!allowThis && !hasChildren) return null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(hasChildren ? { children: filteredChildren } : {}),
|
||||
};
|
||||
})
|
||||
.filter((item): item is NavItem => item !== null);
|
||||
};
|
||||
|
||||
return applyFilter(navItemsStore.navItems) as any[];
|
||||
}
|
||||
|
||||
// If no permissions found, show all items
|
||||
return navItemsStore.navItems;
|
||||
}
|
||||
|
||||
// Use API permissions to filter
|
||||
const permissionMap = new Map(
|
||||
apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm])
|
||||
);
|
||||
|
||||
// Mapping untuk pagename dari API ke nama menu di sidebar
|
||||
const pagenameToMenuMapping: Record<string, string[]> = {
|
||||
'halaman utama': ['dashboard', 'halaman utama'],
|
||||
'pengaturan': ['master data'],
|
||||
'halaman': ['master data'],
|
||||
'dashboard': ['dashboard'],
|
||||
};
|
||||
|
||||
const applyFilter = (items: NavItem[]): NavItem[] => {
|
||||
return items
|
||||
.map((item) => {
|
||||
// Try to match by pagename or menu name
|
||||
let perm = permissionMap.get(item.name.toLowerCase());
|
||||
|
||||
// If no direct match, try fuzzy matching
|
||||
if (!perm) {
|
||||
perm = Array.from(permissionMap.values()).find(p => {
|
||||
const pagenameLower = p.pagename?.toLowerCase() || '';
|
||||
const menuNameLower = item.name.toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (pagenameLower === menuNameLower) return true;
|
||||
|
||||
// Contains match
|
||||
if (pagenameLower.includes(menuNameLower) || menuNameLower.includes(pagenameLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check mapping
|
||||
const mappedMenus = pagenameToMenuMapping[pagenameLower];
|
||||
if (mappedMenus && mappedMenus.some(m => m === menuNameLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
const filteredChildren = item.children ? applyFilter(item.children) : [];
|
||||
const allowThis = perm ? (perm.active || (perm as any).read) : false;
|
||||
const hasChildren = filteredChildren.length > 0;
|
||||
|
||||
// If permission allows and has children, show item with filtered children
|
||||
// If permission allows but no children, show item
|
||||
// If no permission but has allowed children, show item with children
|
||||
if (!allowThis && !hasChildren) return null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(hasChildren ? { children: filteredChildren } : {}),
|
||||
};
|
||||
})
|
||||
.filter((item): item is NavItem => item !== null);
|
||||
};
|
||||
|
||||
return applyFilter(navItemsStore.navItems);
|
||||
});
|
||||
const filteredNavItems = computed(() => navItemsStore.filteredNavItems);
|
||||
|
||||
onMounted(async () => {
|
||||
await checkAuth();
|
||||
// DISABLED: Auto-fetch permissions is now disabled
|
||||
// Permissions should only be fetched manually via button click in HakAkses page
|
||||
// await fetchPermissionsFromAPI();
|
||||
if (user.value) {
|
||||
await navItemsStore.refreshNavItems();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for user changes to refresh navItems
|
||||
watch(() => user.value, async (newUser) => {
|
||||
if (newUser) {
|
||||
await navItemsStore.refreshNavItems();
|
||||
} else {
|
||||
// Optionally reset navItems when logged out
|
||||
navItemsStore.filteredNavItems = [];
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// middleware/checkPageAccess.ts
|
||||
// Middleware to check if user has access to the page based on hakAkses
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
// Skip check for public pages
|
||||
const publicPaths = ['/LoginPage', '/auth/login', '/index-legacy'];
|
||||
|
||||
// index.vue is the debug dashboard, let's keep it accessible for now as requested
|
||||
if (to.path === '/' || publicPaths.includes(to.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Import useAuth and useHakAkses
|
||||
const { user, checkAuth } = useAuth();
|
||||
const { getAllowedPages } = useHakAkses();
|
||||
|
||||
// If user not loaded, try to load
|
||||
if (!user.value) {
|
||||
await checkAuth();
|
||||
}
|
||||
|
||||
// If still not authenticated, redirect to login
|
||||
if (!user.value) {
|
||||
return navigateTo('/LoginPage');
|
||||
}
|
||||
|
||||
try {
|
||||
const allowedPages = await getAllowedPages();
|
||||
|
||||
// Normalize paths for comparison (optional, but good for robustness)
|
||||
const targetPath = to.path.endsWith('/') && to.path.length > 1 ? to.path.slice(0, -1) : to.path;
|
||||
|
||||
// Check if user has access to this page
|
||||
// We also check against the raw path just in case
|
||||
const isAllowed = allowedPages.some(path => {
|
||||
const normalizedAllowed = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
|
||||
return normalizedAllowed === targetPath || path === to.path;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
console.warn(`Access denied to ${to.path}. User allowed pages:`, allowedPages);
|
||||
|
||||
// Redirect to first allowed page if available, else stay/error
|
||||
if (allowedPages.length > 0) {
|
||||
// If dashboard is allowed, go there, else go to the first allowed one
|
||||
const dashboardPath = allowedPages.find(p => p === '/' || p === '/dashboard');
|
||||
return navigateTo(dashboardPath || allowedPages[0]);
|
||||
} else {
|
||||
// No access to any page - technically this shouldn't happen if user has roles
|
||||
console.error('User has roles but no allowed pages found in configuration.');
|
||||
// For now, allow root as fallback since index.vue is kept
|
||||
if (to.path === '/') return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking page access:', error);
|
||||
// On error, we might want to allow or block. Let's allow but log.
|
||||
return;
|
||||
}
|
||||
});
|
||||
+6
-1
@@ -8,7 +8,6 @@ export default defineNuxtConfig({
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
meta: [
|
||||
@@ -16,6 +15,9 @@ export default defineNuxtConfig({
|
||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
|
||||
],
|
||||
link: [
|
||||
{ rel: "icon", type: "image/x-icon", href: "/love%20logo%20biru%20small.ico" },
|
||||
]
|
||||
},
|
||||
},
|
||||
@@ -126,6 +128,9 @@ export default defineNuxtConfig({
|
||||
'/visit-api/**': {
|
||||
proxy: 'http://10.10.123.135:8084/api/v1/**'
|
||||
},
|
||||
'/klinik-api/**': {
|
||||
proxy: 'http://10.10.123.140:8089/api/v1/**'
|
||||
},
|
||||
},
|
||||
|
||||
vite: {
|
||||
|
||||
@@ -707,13 +707,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, watch } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
|
||||
import { useRoute } from "#app";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAnjunganStore } from "@/stores/anjunganStore";
|
||||
import { useClinicStore } from "@/stores/clinicStore";
|
||||
import { useQueueStore } from "@/stores/queueStore";
|
||||
import { useDoctorStore } from "@/stores/doctorStore";
|
||||
import { useLoketStore } from "@/stores/loketStore";
|
||||
import { useThermalPrint } from "@/composables/useThermalPrint";
|
||||
|
||||
definePageMeta({
|
||||
@@ -725,6 +726,7 @@ const anjunganStore = useAnjunganStore();
|
||||
const clinicStore = useClinicStore();
|
||||
const queueStore = useQueueStore();
|
||||
const doctorStore = useDoctorStore();
|
||||
const loketStore = useLoketStore();
|
||||
const { printTicketFromPatient, isPrinting } = useThermalPrint();
|
||||
|
||||
// reactive refs from stores
|
||||
@@ -794,10 +796,10 @@ onMounted(async () => {
|
||||
|
||||
// Use centralized WebSocket
|
||||
queueStore.initWebSocket(anjunganClientId.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// wsInstance is now global
|
||||
});
|
||||
onUnmounted(() => {
|
||||
// wsInstance is now global
|
||||
});
|
||||
|
||||
// Lifecycle management consolidated below
|
||||
|
||||
+1
-1
@@ -448,7 +448,7 @@ dayjs.extend(weekOfYear);
|
||||
dayjs.locale('id');
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
middleware:['auth', 'check-page-access']
|
||||
})
|
||||
|
||||
const user = ref(null);
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware:['auth']
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
|
||||
// === Data Dummy untuk Tabel ===
|
||||
|
||||
+376
-1870
File diff suppressed because it is too large
Load Diff
@@ -248,6 +248,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useAnjunganStore } from '@/stores/anjunganStore';
|
||||
|
||||
@@ -445,6 +445,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useClinicStore } from '@/stores/clinicStore';
|
||||
|
||||
@@ -373,6 +373,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useClinicStore } from '@/stores/clinicStore';
|
||||
|
||||
@@ -403,6 +403,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useLoketStore } from '@/stores/loketStore';
|
||||
|
||||
@@ -403,6 +403,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
|
||||
|
||||
@@ -268,6 +268,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMasterStore } from '@/stores/masterStore';
|
||||
import { useScreenStore } from '@/stores/screenStore';
|
||||
|
||||
@@ -287,6 +287,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'check-page-access']
|
||||
})
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useAntreanMasukScreenStore } from '@/stores/antreanMasukScreenStore';
|
||||
import { useLoketStore } from '@/stores/loketStore';
|
||||
|
||||
@@ -118,16 +118,26 @@
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="editedItem.tipeUser"
|
||||
label="Tipe User"
|
||||
:items="['Super Admin', 'Admin', 'Loket', 'Klinik', 'Admin Barcode', 'INOVA', 'Ranap', 'Report Only', 'Farmasi', 'Manager']"
|
||||
placeholder="Pilih Tipe User"
|
||||
label="Tipe User (Hak Akses)"
|
||||
:items="hakAksesOptions"
|
||||
item-title="nama"
|
||||
item-value="id"
|
||||
placeholder="Pilih Hak Akses"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-account-tie-outline"
|
||||
:readonly="readOnly"
|
||||
hide-details="auto"
|
||||
class="mb-3"
|
||||
></v-select>
|
||||
>
|
||||
<template v-slot:item="{ props, item }">
|
||||
<v-list-item v-bind="props">
|
||||
<v-list-item-subtitle v-if="item.raw.description">
|
||||
{{ item.raw.description }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
@@ -510,12 +520,24 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
// FIX: Explicitly import useAuth from its source path (~/composables/useAuth.ts)
|
||||
// This resolves the 'useAuth is not defined' runtime error during SSR/build.
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
import { useHakAkses } from '~/composables/useHakAkses';
|
||||
import type { HakAkses } from '~/types/setting';
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
|
||||
// Set page metadata (optional, but good practice for a Nuxt page)
|
||||
definePageMeta({
|
||||
middleware: ['auth'] // Example: requires user to be logged in
|
||||
middleware: ['auth', 'check-page-access'] // @ts-ignore - middleware exists
|
||||
});
|
||||
|
||||
const { allHakAksesData, fetchHakAkses } = useHakAkses();
|
||||
|
||||
const hakAksesOptions = computed(() => {
|
||||
return (allHakAksesData.value as HakAkses[]).map((h: HakAkses) => ({
|
||||
id: h.namaHakAkses, // Use role name as the ID/value for tipeUser
|
||||
nama: h.namaHakAkses,
|
||||
description: `${h.pages?.length || 0} Pages accessible`
|
||||
}));
|
||||
});
|
||||
|
||||
// Define the expected structure of user data
|
||||
@@ -703,6 +725,9 @@ const stopAutoRefresh = () => {
|
||||
|
||||
// Auto-sync user on mount (client-side only)
|
||||
onMounted(async () => {
|
||||
// Fetch hak akses data for management
|
||||
await fetchHakAkses();
|
||||
|
||||
// Fetch current user data first
|
||||
await fetchCurrentUser();
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
@@ -0,0 +1,45 @@
|
||||
// scripts/migrate-slugs.js
|
||||
const Database = require('better-sqlite3');
|
||||
const { join } = require('path');
|
||||
|
||||
// Helper to convert string to slug (matching the one in userSync.ts)
|
||||
const slugify = (text) => {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/[^\w-]+/g, '') // Remove all non-word chars
|
||||
.replace(/--+/g, '-'); // Replace multiple - with single -
|
||||
};
|
||||
|
||||
const dbPath = join(__dirname, '..', 'data', 'users.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
const users = db.prepare('SELECT id, tipeUser FROM users').all();
|
||||
console.log(`🔍 Found ${users.length} users to check...`);
|
||||
|
||||
const updateStmt = db.prepare('UPDATE users SET tipeUser = ? WHERE id = ?');
|
||||
|
||||
let updatedCount = 0;
|
||||
db.transaction(() => {
|
||||
for (const user of users) {
|
||||
if (!user.tipeUser) continue;
|
||||
|
||||
const slug = slugify(user.tipeUser);
|
||||
if (slug !== user.tipeUser) {
|
||||
updateStmt.run(slug, user.id);
|
||||
console.log(`✅ Updated User ID ${user.id}: "${user.tipeUser}" -> "${slug}"`);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(`🎉 Migration complete. Updated ${updatedCount} user(s).`);
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -153,6 +153,7 @@ export default defineEventHandler(async (event) => {
|
||||
// Parse token payloads for immediate availability
|
||||
let accessTokenPayload;
|
||||
let idTokenPayloadFull;
|
||||
let refreshTokenPayload;
|
||||
try {
|
||||
accessTokenPayload = JSON.parse(
|
||||
Buffer.from(tokens.access_token.split(".")[1], "base64").toString(),
|
||||
@@ -160,12 +161,26 @@ export default defineEventHandler(async (event) => {
|
||||
idTokenPayloadFull = JSON.parse(
|
||||
Buffer.from(tokens.id_token.split(".")[1], "base64").toString(),
|
||||
);
|
||||
if (tokens.refresh_token) {
|
||||
refreshTokenPayload = JSON.parse(
|
||||
Buffer.from(tokens.refresh_token.split(".")[1], "base64").toString(),
|
||||
);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("❌ Failed to parse token payloads:", parseError);
|
||||
const errorMsg = encodeURIComponent("Invalid token format");
|
||||
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
||||
}
|
||||
|
||||
// Determine session expiration from refresh token (preferred) or default
|
||||
const expiresAt = refreshTokenPayload?.exp
|
||||
? refreshTokenPayload.exp * 1000
|
||||
: Date.now() + SESSION_DURATION * 1000;
|
||||
|
||||
const createdAt = refreshTokenPayload?.iat
|
||||
? refreshTokenPayload.iat * 1000
|
||||
: Date.now();
|
||||
|
||||
// Create session data
|
||||
const sessionData = {
|
||||
user: {
|
||||
@@ -177,8 +192,8 @@ export default defineEventHandler(async (event) => {
|
||||
accessToken: tokens.access_token,
|
||||
idToken: tokens.id_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: Date.now() + SESSION_DURATION * 1000,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: expiresAt,
|
||||
createdAt: createdAt,
|
||||
scope: accessTokenPayload.scope || "openid email profile",
|
||||
status: "authenticated",
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
|
||||
// Parse token payloads on-demand from tokens
|
||||
let accessTokenPayload = null;
|
||||
let idTokenPayload = null;
|
||||
let refreshTokenPayload = null;
|
||||
|
||||
try {
|
||||
if (session.accessToken) {
|
||||
@@ -49,10 +50,22 @@ export default defineEventHandler(async (event) => {
|
||||
idTokenPayload = JSON.parse(Buffer.from(idParts[1], 'base64').toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (session.refreshToken) {
|
||||
const refreshParts = session.refreshToken.split('.');
|
||||
if (refreshParts.length >= 2) {
|
||||
refreshTokenPayload = JSON.parse(Buffer.from(refreshParts[1], 'base64').toString());
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('⚠️ Failed to parse token payloads:', parseError);
|
||||
}
|
||||
|
||||
// Use dynamic expiration from refresh token if available
|
||||
if (refreshTokenPayload?.exp) {
|
||||
session.expiresAt = refreshTokenPayload.exp * 1000;
|
||||
}
|
||||
|
||||
|
||||
const isExpired = Date.now() > session.expiresAt;
|
||||
console.log(' Is Expired:', isExpired);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// server/api/auth/session.patch.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log("🔄 Session update endpoint called");
|
||||
|
||||
const sessionId = getCookie(event, "user_session");
|
||||
|
||||
if (!sessionId) {
|
||||
console.log("❌ No session cookie found");
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "No session cookie found",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the update data from request body
|
||||
const body = await readBody(event);
|
||||
const { accessToken, idToken, refreshToken, expiresAt } = body;
|
||||
|
||||
// Validate that at least one token is provided
|
||||
if (!accessToken && !idToken && !refreshToken) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "At least one token must be provided",
|
||||
});
|
||||
}
|
||||
|
||||
// Get session store functions
|
||||
const { getSession, updateSession } = await import('~/server/utils/sessionStore');
|
||||
|
||||
// Verify session exists
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log("❌ Session not found");
|
||||
deleteCookie(event, "user_session");
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Session not found or expired",
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare updates object
|
||||
const updates: any = {};
|
||||
if (accessToken) updates.accessToken = accessToken;
|
||||
if (idToken) updates.idToken = idToken;
|
||||
if (refreshToken) updates.refreshToken = refreshToken;
|
||||
if (expiresAt) updates.expiresAt = expiresAt;
|
||||
|
||||
// Update the session
|
||||
const updated = updateSession(sessionId, updates);
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to update session",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Session updated successfully");
|
||||
return {
|
||||
success: true,
|
||||
message: "Session updated successfully",
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ Failed to update session:", error);
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: error.statusMessage || "Failed to update session",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// server/api/hak-akses/entities.get.ts
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
// Helper to get database path
|
||||
const getDbPath = () => {
|
||||
return join(process.cwd(), 'data', 'users.db');
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log("📥 Fetching entities from local database (users.db)");
|
||||
|
||||
try {
|
||||
const dbPath = getDbPath();
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { roles: [], groups: [], users: [] }
|
||||
};
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// 1. Get all users
|
||||
const users = db.prepare('SELECT id, namaLengkap, namaUser, email, realmRoles, groups FROM users ORDER BY namaLengkap ASC').all() as any[];
|
||||
|
||||
const allRoles = new Set<string>();
|
||||
const allGroups = new Set<string>();
|
||||
const formattedUsers: any[] = [];
|
||||
|
||||
users.forEach(user => {
|
||||
// Collect unique roles
|
||||
const roles = JSON.parse(user.realmRoles || '[]');
|
||||
roles.forEach((r: string) => allRoles.add(r));
|
||||
|
||||
// Collect unique groups
|
||||
const groups = JSON.parse(user.groups || '[]');
|
||||
groups.forEach((g: string) => allGroups.add(g));
|
||||
|
||||
// Format individual user
|
||||
formattedUsers.push({
|
||||
id: user.id,
|
||||
name: user.namaLengkap,
|
||||
username: user.namaUser,
|
||||
email: user.email,
|
||||
type: 'user'
|
||||
});
|
||||
});
|
||||
|
||||
db.close();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
roles: Array.from(allRoles).sort().map(name => ({
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'role'
|
||||
})),
|
||||
groups: Array.from(allGroups).sort().map(name => ({
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'group'
|
||||
})),
|
||||
users: formattedUsers
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error fetching local entities:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || "Failed to fetch local entities",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { HakAkses } from '~/types/setting';
|
||||
import { randomUUID } from 'node:crypto'; // Use standard node crypto
|
||||
|
||||
const filePath = path.resolve('data/mock/hakAkses.json');
|
||||
|
||||
// Helper to read JSON file
|
||||
const readData = (): HakAkses[] => {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, '[]', 'utf-8');
|
||||
return [];
|
||||
}
|
||||
const data = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('Error reading hakAkses.json:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to write JSON file
|
||||
const writeData = (data: HakAkses[]): boolean => {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 4), 'utf-8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error writing hakAkses.json:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const method = event.method;
|
||||
|
||||
// GET - List all hak akses
|
||||
if (method === 'GET') {
|
||||
const data = readData();
|
||||
return {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
// POST - Create or Update hak akses
|
||||
if (method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const data = readData();
|
||||
|
||||
if (body.id) {
|
||||
// Update existing
|
||||
const index = data.findIndex(h => h.id === body.id);
|
||||
if (index !== -1) {
|
||||
data[index] = {
|
||||
...data[index],
|
||||
...body
|
||||
};
|
||||
} else {
|
||||
data.push(body);
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const newId = randomUUID();
|
||||
const newHakAkses: HakAkses = {
|
||||
id: newId,
|
||||
namaHakAkses: body.namaHakAkses,
|
||||
status: body.status || 'aktif',
|
||||
pages: body.pages || []
|
||||
};
|
||||
data.push(newHakAkses);
|
||||
}
|
||||
|
||||
const success = writeData(data);
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Hak akses berhasil disimpan',
|
||||
data: body.id ? body : data[data.length - 1]
|
||||
};
|
||||
} else {
|
||||
throw new Error('Failed to save data');
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Gagal menyimpan hak akses',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Method not allowed'
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// server/api/hak-akses/members.get.ts
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
// Helper to get database path
|
||||
const getDbPath = () => {
|
||||
return join(process.cwd(), 'data', 'users.db');
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const { type, name, id } = query;
|
||||
|
||||
if (!type || (!name && !id)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Type and (Name or ID) are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const dbPath = getDbPath();
|
||||
if (!existsSync(dbPath)) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const users = db.prepare('SELECT id, namaLengkap, namaUser, email, realmRoles, groups FROM users').all() as any[];
|
||||
db.close();
|
||||
|
||||
let filteredUsers = [];
|
||||
|
||||
if (type === 'role') {
|
||||
filteredUsers = users.filter(user => {
|
||||
const roles = JSON.parse(user.realmRoles || '[]');
|
||||
return roles.includes(name);
|
||||
});
|
||||
} else if (type === 'group') {
|
||||
filteredUsers = users.filter(user => {
|
||||
const groups = JSON.parse(user.groups || '[]');
|
||||
return groups.includes(name);
|
||||
});
|
||||
} else if (type === 'user') {
|
||||
// For type 'user', name is the username
|
||||
filteredUsers = users.filter(user => user.namaUser === name);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: filteredUsers.map((m: any) => ({
|
||||
id: m.id,
|
||||
username: m.namaUser,
|
||||
email: m.email,
|
||||
name: m.namaLengkap
|
||||
}))
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error fetching local members:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || "Failed to fetch local members",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -107,8 +107,10 @@ const normalizeGroup = (group: string): string => {
|
||||
};
|
||||
|
||||
// Normalize role name
|
||||
const normalizeRole = (role: string): string => {
|
||||
const normalized = role.toLowerCase().trim();
|
||||
const normalizeRole = async (role: string): Promise<string> => {
|
||||
const { slugify } = await import('~/server/utils/userSync');
|
||||
const normalized = slugify(role);
|
||||
|
||||
// Mapping khusus untuk role default
|
||||
if (normalized === 'default-roles-sandbox') {
|
||||
return 'superadmin';
|
||||
@@ -117,8 +119,8 @@ const normalizeRole = (role: string): string => {
|
||||
};
|
||||
|
||||
// Get placeholder data for a role+group combination
|
||||
const getPlaceholderData = (role: string, group: string): any | null => {
|
||||
const normalizedRole = normalizeRole(role);
|
||||
const getPlaceholderData = async (role: string, group: string): Promise<any | null> => {
|
||||
const normalizedRole = await normalizeRole(role);
|
||||
const normalizedGroup = normalizeGroup(group);
|
||||
const key = `${normalizedRole}_${normalizedGroup}`;
|
||||
|
||||
@@ -166,14 +168,14 @@ export default defineEventHandler(async (event) => {
|
||||
let primaryGroup = groupsArray[0] || '';
|
||||
|
||||
// Normalize role and group
|
||||
primaryRole = normalizeRole(primaryRole);
|
||||
primaryRole = await normalizeRole(primaryRole);
|
||||
primaryGroup = normalizeGroup(primaryGroup);
|
||||
|
||||
console.log(`📋 Normalized params - roles: ${primaryRole}, groups: ${primaryGroup}`);
|
||||
|
||||
// Check for placeholder data first if placeholder mode is forced
|
||||
if (forcePlaceholder && !disablePlaceholder) {
|
||||
const placeholderData = getPlaceholderData(primaryRole, primaryGroup);
|
||||
const placeholderData = await getPlaceholderData(primaryRole, primaryGroup);
|
||||
if (placeholderData) {
|
||||
console.log(`📦 Using placeholder data (forced) for role: ${primaryRole}, group: ${primaryGroup}`);
|
||||
return placeholderData;
|
||||
@@ -226,7 +228,7 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
|
||||
// Fallback to placeholder data if available
|
||||
const placeholderData = getPlaceholderData(primaryRole, primaryGroup);
|
||||
const placeholderData = await getPlaceholderData(primaryRole, primaryGroup);
|
||||
if (placeholderData) {
|
||||
console.log(`📦 Falling back to placeholder data for role: ${primaryRole}, group: ${primaryGroup}`);
|
||||
return placeholderData;
|
||||
|
||||
@@ -73,21 +73,41 @@ export default defineEventHandler(async (event) => {
|
||||
idTokenPayload?.groups ||
|
||||
[];
|
||||
|
||||
// Determine tipeUser from groups or roles if possible
|
||||
// You can customize this mapping based on your business logic
|
||||
// Build user data object
|
||||
let tipeUser = '';
|
||||
if (Array.isArray(groups) && groups.length > 0) {
|
||||
// Extract tipeUser from groups path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "Superadmin")
|
||||
|
||||
// Check if tipeUser exists in database
|
||||
try {
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
const { join } = await import('path');
|
||||
const { existsSync } = await import('fs');
|
||||
|
||||
const dbPath = join(process.cwd(), 'data', 'users.db');
|
||||
if (existsSync(dbPath)) {
|
||||
const db = new Database(dbPath);
|
||||
const dbUser = db.prepare('SELECT tipeUser FROM users WHERE id = ?').get(idTokenPayload?.sub || session.user?.id) as any;
|
||||
if (dbUser && dbUser.tipeUser) {
|
||||
tipeUser = dbUser.tipeUser;
|
||||
console.log(`✅ Using tipeUser from database: ${tipeUser}`);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.warn('⚠️ Failed to fetch tipeUser from database:', dbError);
|
||||
}
|
||||
|
||||
// Fallback to groups if not in database
|
||||
if (!tipeUser && Array.isArray(groups) && groups.length > 0) {
|
||||
const { slugify } = await import('~/server/utils/userSync');
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (typeof lastGroup === 'string') {
|
||||
const parts = lastGroup.split('/').filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
tipeUser = parts[parts.length - 1]; // Get last part of path
|
||||
tipeUser = slugify(parts[parts.length - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build user data object
|
||||
const userData = {
|
||||
id: idTokenPayload?.sub || session.user?.id,
|
||||
namaLengkap: idTokenPayload?.name ||
|
||||
@@ -105,13 +125,19 @@ export default defineEventHandler(async (event) => {
|
||||
accountRoles: Array.isArray(accountRoles) ? accountRoles : [],
|
||||
resourceRoles: Array.isArray(resourceRoles) ? resourceRoles : [],
|
||||
groups: Array.isArray(groups) ? groups : [],
|
||||
tipeUser: tipeUser, // Extracted from groups or empty
|
||||
tipeUser: tipeUser,
|
||||
lastLogin: null, // Will be set on sync
|
||||
// Include full token payloads for reference
|
||||
idTokenPayload,
|
||||
accessTokenPayload,
|
||||
};
|
||||
|
||||
// IMPORTANT: Inject tipeUser as a role so checkPageAccess/useHakAkses can find it
|
||||
if (tipeUser && !userData.roles.includes(tipeUser)) {
|
||||
userData.roles.push(tipeUser);
|
||||
console.log(`🛡️ Injected tipeUser '${tipeUser}' as a role for access control`);
|
||||
}
|
||||
|
||||
console.log("✅ Current user data extracted from JWT");
|
||||
return userData;
|
||||
} catch (parseError: any) {
|
||||
|
||||
@@ -65,6 +65,22 @@ export function deleteSession(sessionId: string): void {
|
||||
console.log(`🗑️ Session deleted: ${sessionId.substring(0, 8)}... (Remaining: ${sessions.size})`);
|
||||
}
|
||||
|
||||
export function updateSession(sessionId: string, updates: Partial<SessionData>): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the session with new data
|
||||
const updatedSession = {
|
||||
...session,
|
||||
...updates,
|
||||
};
|
||||
|
||||
sessions.set(sessionId, updatedSession);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper function to get session from cookie (for API handlers)
|
||||
export async function getSessionFromCookie(event: any): Promise<SessionData | null> {
|
||||
const { getCookie } = await import('h3');
|
||||
|
||||
@@ -97,6 +97,18 @@ const decodeTokenPayload = (token: string | undefined): any | null => {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to convert string to slug
|
||||
export const slugify = (text: string): string => {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/[^\w-]+/g, '') // Remove all non-word chars
|
||||
.replace(/--+/g, '-'); // Replace multiple - with single -
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync user data from JWT tokens to database
|
||||
* @param idToken - The ID token from Keycloak
|
||||
@@ -178,7 +190,7 @@ export const syncUserFromTokens = (
|
||||
if (typeof lastGroup === 'string') {
|
||||
const parts = lastGroup.split('/').filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
tipeUser = parts[parts.length - 1]; // Get last part of path
|
||||
tipeUser = slugify(parts[parts.length - 1]); // Get last part of path and slugify it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,21 +773,22 @@ export const useClinicStore = defineStore('clinic', () => {
|
||||
|
||||
try {
|
||||
console.log(`🔄 Fetching clinics from API (Try ${retryCount + 1})...`);
|
||||
const response = await fetch('http://10.10.123.140:8089/api/v1/klinik/reguler');
|
||||
|
||||
if (!response.ok) {
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch('/klinik-api/klinik/reguler');
|
||||
} catch (error) {
|
||||
// Handle Rate Limiting with exponential backoff
|
||||
if (response.status === 429 && retryCount < 3) {
|
||||
if (error.response?.status === 429 && retryCount < 3) {
|
||||
const delay = (retryCount + 1) * 1500;
|
||||
console.warn(`⚠️ Rate limit hit (429). Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
activeFetchPromise = null; // Reset so next try can start fresh
|
||||
return fetchRegulerClinics(force, retryCount + 1);
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
console.log('📦 Raw API Response received');
|
||||
|
||||
// Handle different response formats
|
||||
|
||||
+11
-12
@@ -296,20 +296,19 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
|
||||
try {
|
||||
console.log(`🔄 [loketStore] Fetching loket configuration (Try ${retryCount + 1})...`);
|
||||
const response = await fetch('http://10.10.123.140:8089/api/v1/loket');
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429 && retryCount < 3) {
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch('/klinik-api/klinik/loket');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 429 && retryCount < 3) {
|
||||
const delay = (retryCount + 1) * 1500;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
activeFetchPromise = null;
|
||||
return fetchLoketFromAPI(force, retryCount + 1);
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const loketsRaw = rawData.data || [];
|
||||
|
||||
// MAPPING: Convert API Structure to Store Format
|
||||
@@ -436,13 +435,13 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
|
||||
try {
|
||||
console.log(`🔄 [loketStore] Fetching detail for loket ${loketId}...`);
|
||||
const response = await fetch(`http://10.10.123.140:8089/api/v1/loket/${loketId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
let rawData;
|
||||
try {
|
||||
rawData = await $fetch(`/klinik-api/klinik/loket/${loketId}`);
|
||||
} catch (error) {
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
if (rawData.metadata && rawData.metadata.code !== 200) {
|
||||
throw new Error(rawData.message || 'API returned error status');
|
||||
|
||||
+48
-1
@@ -1,7 +1,8 @@
|
||||
// stores/navItems.ts
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { computed } from 'vue'; // Import computed dari Vue
|
||||
import { computed, ref } from 'vue'; // Import computed dari Vue
|
||||
import { useHakAkses } from '~/composables/useHakAkses';
|
||||
|
||||
interface NavItem {
|
||||
id: number;
|
||||
@@ -62,9 +63,19 @@ const defaultNavItems: NavItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_VERSION = '1.1'; // Increment this if you change structure or default paths
|
||||
|
||||
export const useNavItemsStore = defineStore('navItems', () => {
|
||||
const storedVersion = useLocalStorage('navItems_version', '0');
|
||||
const navItems = useLocalStorage<NavItem[]>('navItems', defaultNavItems);
|
||||
|
||||
// Force reset if version mismatch (stale paths in localStorage)
|
||||
if (storedVersion.value !== STORAGE_VERSION) {
|
||||
console.log(`🔄 Version mismatch: ${storedVersion.value} -> ${STORAGE_VERSION}. Resetting navItems...`);
|
||||
navItems.value = JSON.parse(JSON.stringify(defaultNavItems));
|
||||
storedVersion.value = STORAGE_VERSION;
|
||||
}
|
||||
|
||||
// === GETTER PENTING UNTUK MENGATASI MASALAH 'NULL' DI AWAL ===
|
||||
const getNavItems = computed(() => {
|
||||
// Jika navItems.value null, undefined, atau bukan array yang valid,
|
||||
@@ -88,9 +99,45 @@ export const useNavItemsStore = defineStore('navItems', () => {
|
||||
navItems.value.push({ ...newItem, id: newId });
|
||||
}
|
||||
|
||||
// Filtered navigation items based on hakAkses
|
||||
const filteredNavItems = ref<NavItem[]>(defaultNavItems);
|
||||
|
||||
async function refreshNavItems() {
|
||||
const { getAllowedPages } = useHakAkses();
|
||||
const allowedPages = await getAllowedPages();
|
||||
|
||||
if (allowedPages.length === 0) {
|
||||
// If no hak akses defined (maybe new system not setup yet),
|
||||
// keep default or clear? Let's keep for now for safety during transition
|
||||
filteredNavItems.value = defaultNavItems;
|
||||
return;
|
||||
}
|
||||
|
||||
const filterItems = (items: NavItem[]): NavItem[] => {
|
||||
return items.filter(item => {
|
||||
// If it's a parent with no path, check if any children are allowed
|
||||
if (!item.path && item.children) {
|
||||
const allowedChildren = filterItems(item.children);
|
||||
if (allowedChildren.length > 0) {
|
||||
item.children = allowedChildren;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a child or leaf, check if path is in allowedPages
|
||||
return allowedPages.includes(item.path);
|
||||
});
|
||||
};
|
||||
|
||||
filteredNavItems.value = filterItems(JSON.parse(JSON.stringify(defaultNavItems)));
|
||||
}
|
||||
|
||||
return {
|
||||
navItems,
|
||||
getNavItems, // Diekspor untuk digunakan di Sidebar
|
||||
filteredNavItems,
|
||||
refreshNavItems,
|
||||
updateNavItems,
|
||||
addNavItem,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface HakAksesPage {
|
||||
id: string;
|
||||
namaPage: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface HakAkses {
|
||||
id: string;
|
||||
namaHakAkses: string;
|
||||
status: "aktif" | "tidak aktif";
|
||||
pages: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user