551 lines
18 KiB
Vue
551 lines
18 KiB
Vue
<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-tab value="users" class="text-capitalize">
|
|
<v-icon start>mdi-account-outline</v-icon>
|
|
Users
|
|
</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' : activeTab === 'groups' ? 'Group' : 'User'}...`"
|
|
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' : activeTab === 'groups' ? 'Groups' : 'Users' }}
|
|
</v-chip>
|
|
</div>
|
|
<v-btn
|
|
color="primary"
|
|
@click="fullSync"
|
|
prepend-icon="mdi-sync"
|
|
variant="flat"
|
|
rounded="lg"
|
|
:loading="loading"
|
|
>
|
|
Sync Data Lokal
|
|
</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="item.type === 'role' ? 'mdi-account-star' : item.type === 'group' ? 'mdi-folder-account' : 'mdi-account-outline'"
|
|
size="small"
|
|
color="primary"
|
|
class="mr-2"
|
|
></v-icon>
|
|
<div>
|
|
<div class="font-weight-bold text-primary-darken-1">{{ item.name }}</div>
|
|
<div v-if="item.type === 'user'" class="text-caption text-grey">@{{ item.username }}</div>
|
|
</div>
|
|
</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
|
|
v-if="item.type !== 'user'"
|
|
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>
|
|
<span v-else class="text-caption text-grey italic">Individual User</span>
|
|
</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[], users: any[] }>({ roles: [], groups: [], users: [] });
|
|
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' }
|
|
];
|
|
|
|
// Data loading and syncing
|
|
const fullSync = async () => {
|
|
loading.value = true;
|
|
try {
|
|
showSnackbar('Menyingkronkan data user dari Keycloak...', 'info');
|
|
await $fetch('/api/users/sync-all', { method: 'POST' });
|
|
showSnackbar('Sinkronisasi selesai. Memperbarui daftar...', 'success');
|
|
await loadData();
|
|
} catch (error) {
|
|
console.error('Error syncing:', error);
|
|
showSnackbar('Gagal sinkronisasi data', 'error');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
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 entities from local database
|
|
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 lokal', 'error');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Computed list based on active tab
|
|
const displayItems = computed(() => {
|
|
let list = [];
|
|
if (activeTab.value === 'roles') list = keycloakEntities.value.roles;
|
|
else if (activeTab.value === 'groups') list = keycloakEntities.value.groups;
|
|
else list = keycloakEntities.value.users;
|
|
|
|
// 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
|
|
// For roles/groups, name matches. For users, username matches.
|
|
const lookupKey = entity.type === 'user' ? entity.username : entity.name;
|
|
const mapping = hakAksesList.value.find(h => h.namaHakAkses === lookupKey);
|
|
|
|
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;
|
|
const searchTarget = item.type === 'user' ? `${item.name} ${item.username}` : item.name;
|
|
return searchTarget.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> |