update hak akses dan perbaikan api pembuatan tiket

This commit is contained in:
Fanrouver
2026-04-20 09:42:03 +07:00
parent 94ff9f55d9
commit 2bf00eff3c
38 changed files with 2054 additions and 2552 deletions
+520
View File
@@ -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>
+93
View File
@@ -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",
});
}
});
+74
View File
@@ -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",
});
}
});
+113
View File
@@ -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
};
};
+77 -424
View File
@@ -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>
+117
View File
@@ -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
View File
@@ -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>
+60
View File
@@ -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
View File
@@ -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: {
View File
+6 -4
View File
@@ -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
View File
@@ -448,7 +448,7 @@ dayjs.extend(weekOfYear);
dayjs.locale('id');
definePageMeta({
middleware:['auth']
middleware:['auth', 'check-page-access']
})
const user = ref(null);
+1 -1
View File
@@ -112,7 +112,7 @@
import { ref, computed, watch } from "vue";
definePageMeta({
middleware:['auth']
middleware: ['auth', 'check-page-access']
})
// === Data Dummy untuk Tabel ===
+376 -1870
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -403,6 +403,9 @@
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'check-page-access']
})
import { ref, computed } from 'vue';
import { useMasterStore } from '@/stores/masterStore';
+3
View File
@@ -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';
+3
View File
@@ -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';
+30 -5
View File
@@ -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

+45
View File
@@ -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();
}
+17 -2
View File
@@ -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",
};
+13
View File
@@ -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);
+71
View File
@@ -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",
});
}
});
+78
View File
@@ -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",
});
}
});
+104
View File
@@ -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'
};
});
+66
View File
@@ -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",
});
}
});
+9 -7
View File
@@ -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;
+33 -7
View File
@@ -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) {
+16
View File
@@ -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');
+13 -1
View File
@@ -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
}
}
}
+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+13
View File
@@ -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[];
}