feat: implement role-based access control (RBAC) management system with Keycloak integration and dynamic page permission configuration

This commit is contained in:
Fanrouver
2026-07-14 09:41:55 +07:00
parent a5d7338e58
commit 49e0ee2bc4
14 changed files with 737 additions and 163 deletions

No files matched your search

+95 -31
View File
@@ -4,29 +4,76 @@
<thead>
<tr>
<th class="text-left font-weight-bold">Halaman</th>
<th class="text-center font-weight-bold" style="width: 120px;">Akses</th>
<th class="text-center font-weight-bold" style="width: 80px;">Access</th>
<th class="text-center font-weight-bold" style="width: 80px;">View</th>
<th class="text-center font-weight-bold" style="width: 80px;">Add</th>
<th class="text-center font-weight-bold" style="width: 80px;">Edit</th>
<th class="text-center font-weight-bold" style="width: 80px;">Delete</th>
</tr>
</thead>
<tbody>
<template v-for="item in flatNavItems" :key="item.path || item.name">
<tr :class="{ 'bg-grey-lighten-4': !item.path }">
<template v-for="item in flatNavItems" :key="item.menuKey || item.name">
<tr :class="{ 'bg-grey-lighten-4': !item.menuKey }">
<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>
<span :class="{ 'font-weight-bold': !item.menuKey }">{{ 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)"
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canAccess')"
@update:model-value="toggleAccess(item, 'canAccess', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canView')"
@update:model-value="toggleAccess(item, 'canView', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canAdd')"
@update:model-value="toggleAccess(item, 'canAdd', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canEdit')"
@update:model-value="toggleAccess(item, 'canEdit', !!$event)"
hide-details
density="compact"
color="primary"
class="d-inline-flex"
></v-checkbox>
</td>
<td class="text-center">
<v-checkbox
v-if="item.menuKey"
:model-value="getPermission(item.menuKey, 'canDelete')"
@update:model-value="toggleAccess(item, 'canDelete', !!$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>
@@ -37,24 +84,24 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useNavItemsStore } from '~/stores/navItems1';
import { defaultNavItems } from '~/stores/navItems1';
import type { HakAksesMenu } from '~/server/utils/schemas/permissionSchema';
const props = defineProps({
pages: {
type: Array as () => any[], // Be flexible with legacy data
hakAksesMenu: {
type: Array as () => HakAksesMenu[],
default: () => []
}
});
const emit = defineEmits(['update:pages']);
const navItemsStore = useNavItemsStore();
const emit = defineEmits(['update:hakAksesMenu']);
interface FlatNavItem {
name: string;
path: string;
icon: string;
level: number;
menuKey?: string;
}
const flatNavItems = computed(() => {
@@ -66,7 +113,8 @@ const flatNavItems = computed(() => {
name: item.name,
path: item.path,
icon: item.icon,
level
level,
menuKey: item.menuKey
});
if (item.children && item.children.length > 0) {
walk(item.children, level + 1);
@@ -74,32 +122,48 @@ const flatNavItems = computed(() => {
});
};
walk(navItemsStore.getNavItems);
walk(defaultNavItems);
return result;
});
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 getPermission = (menuKey: string, action: keyof HakAksesMenu): boolean => {
if (!props.hakAksesMenu || !Array.isArray(props.hakAksesMenu)) return false;
const menuConfig = props.hakAksesMenu.find((p: HakAksesMenu) => p.menuKey === menuKey);
if (!menuConfig) return false;
return !!menuConfig[action];
};
const toggleAccess = (item: FlatNavItem, allowed: boolean) => {
let newPages = [...props.pages];
const toggleAccess = (item: FlatNavItem, action: keyof HakAksesMenu, value: boolean) => {
if (!item.menuKey) return;
if (allowed) {
if (!newPages.includes(item.path)) {
newPages.push(item.path);
// Clone current array
let newMenus = JSON.parse(JSON.stringify(props.hakAksesMenu || []));
let menuIndex = newMenus.findIndex((m: HakAksesMenu) => m.menuKey === item.menuKey);
if (menuIndex === -1) {
if (value) {
newMenus.push({
menuKey: item.menuKey,
name: item.name,
canAccess: action === 'canAccess' ? true : false,
canView: action === 'canView' ? true : false,
canAdd: action === 'canAdd' ? true : false,
canEdit: action === 'canEdit' ? true : false,
canDelete: action === 'canDelete' ? true : false
});
}
} else {
newPages = newPages.filter(p => p !== item.path);
newMenus[menuIndex][action] = value;
// Auto-grant canAccess and canView if Add/Edit/Delete is checked
if (value && (action === 'canAdd' || action === 'canEdit' || action === 'canDelete')) {
newMenus[menuIndex].canAccess = true;
newMenus[menuIndex].canView = true;
}
// If all are false, maybe remove it, but keeping it is fine too
}
emit('update:pages', newPages);
emit('update:hakAksesMenu', newMenus);
};
</script>