feat: implement role-based access control (RBAC) management system with Keycloak integration and dynamic page permission configuration
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Integrasi Hak Akses (Permissions) di UI beserta Konfigurasi CRUD
|
||||
|
||||
## ✅ Hasil Pengecekan Kesiapan Project
|
||||
1. **Navigasi (*Menu/Routing*)**: Sudah tersedia di `stores/navItems1.ts` (`defaultNavItems`). Array ini akan menjadi sumber daftar halaman di dialog "Edit Hak Akses" — dengan catatan penting di bagian struktur data (lihat poin 2 di bawah).
|
||||
2. **Library Komponen**: Vuetify 3 sudah tersedia, tinggal memasang `<v-checkbox>` / `<v-switch>` di tabel dialog Edit Hak Akses.
|
||||
3. **Mock Backend API**: `server/api/hak-akses/index.ts` sudah bisa menahan format data CRUD yang akan dikirim dari UI.
|
||||
|
||||
Karena API backend sesungguhnya belum siap, kita pakai **Nuxt Local API (Mock Backend)** sebagai *Backend-for-Frontend* sementara, dengan arsitektur *toggling* API sejak awal agar migrasi ke backend asli mulus.
|
||||
|
||||
> ⚠️ **Catatan penting**: Toggle mock/real API ini menyelesaikan masalah *sumber data*, bukan masalah *keamanan*. Semua pengecekan `v-permission` dan middleware di bawah ini berjalan di client — lihat bagian **Keamanan** sebelum dianggap selesai.
|
||||
|
||||
---
|
||||
|
||||
## Bagan Alur Sistem Hak Akses (Permission Flow)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User([Pengguna]) --> Login(Berhasil Login via Keycloak)
|
||||
Login --> Auth[useAuth.ts: Dapat Data Roles & Groups]
|
||||
Auth --> PermStore[permissionStore.ts memuat Permissions]
|
||||
|
||||
PermStore --> CheckFlag{USE_MOCK_API ?}
|
||||
|
||||
CheckFlag -- TRUE --> MockAPI[Nuxt Local API: /api/hak-akses]
|
||||
CheckFlag -- FALSE --> RealAPI[Real Backend: /api/v1/permission]
|
||||
|
||||
MockAPI --> FetchFail{Fetch gagal?}
|
||||
RealAPI --> FetchFail
|
||||
FetchFail -- Ya --> DenyDefault[Deny-by-default: anggap tanpa izin]
|
||||
FetchFail -- Tidak --> PermState(Permission State Disimpan)
|
||||
|
||||
PermState --> Router[Vue Router Middleware - client]
|
||||
PermState --> ServerCheck[Server Middleware/Plugin - SSR guard]
|
||||
PermState --> Sidebar[Sidebar Navigation]
|
||||
|
||||
Router -- canAccess: False --> Deny[Redirect ke Error/403]
|
||||
Router -- canAccess: True --> Page[Buka Halaman]
|
||||
ServerCheck -- canAccess: False --> Deny
|
||||
|
||||
Sidebar -- canView: False --> HideMenu[Sembunyikan Menu]
|
||||
|
||||
Page --> Directive[v-permission directive pada Komponen]
|
||||
Directive -- canDelete: False --> HideBtn[Tombol Hapus Hilang/Disabled]
|
||||
Directive -- canAdd: True --> ShowBtn[Tombol Tambah Tampil]
|
||||
|
||||
Page -.-> BackendGuard[[Backend API tetap validasi ulang izin]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Seamless Backend Migration Strategy (Persiapan API Asli)
|
||||
- `stores/permissionStore.ts` dan composable pemanggil membaca konfigurasi *runtime* (`useRuntimeConfig()`).
|
||||
- Tambahkan flag `USE_MOCK_PERMISSION_API: true`.
|
||||
- Saat backend siap, cukup ubah `NUXT_PUBLIC_USE_MOCK_PERMISSION_API=false` di `.env`.
|
||||
- **Tambahan — contract test**: buat satu skema/interface TypeScript (idealnya divalidasi dengan `zod`) yang dipakai bersama oleh mock API dan dipakai untuk memvalidasi response real API nanti. Ini memastikan klaim "tinggal ganti flag" benar-benar teruji, bukan asumsi.
|
||||
|
||||
### 2. Upgrade Local API Mock (`server/api/hak-akses/index.ts`)
|
||||
- Perbarui `data/mock/hakAkses.json` agar field `hakAksesMenu` berisi boolean: `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`.
|
||||
- **Perubahan struktur — pakai key stabil, bukan label**: setiap entri `hakAksesMenu` menyimpan `menuKey` (mengacu ke `key`/`routeName` unik di `navItems1.ts`), bukan `name` (label tampilan). Label bisa berubah/di-rename tanpa memutus mapping izin.
|
||||
|
||||
```json
|
||||
{
|
||||
"menuKey": "master-klinik-ruang",
|
||||
"name": "Master Klinik Ruang",
|
||||
"canAccess": true,
|
||||
"canView": true,
|
||||
"canAdd": false,
|
||||
"canEdit": false,
|
||||
"canDelete": false
|
||||
}
|
||||
```
|
||||
|
||||
- Validasi payload masuk dengan skema (zod) di endpoint mock, supaya struktur tidak diam-diam berubah antara UI dan backend.
|
||||
|
||||
### 3. Memperbarui `Setting/HakAkses.vue` (UI Konfigurasi CRUD)
|
||||
- Dialog Edit melooping `navItems1.ts`, menampilkan matriks checkbox untuk `canAccess`, `canView`, `canAdd`, `canEdit`, `canDelete`, dikunci ke `menuKey` masing-masing.
|
||||
- Payload POST mengarah ke API Mock/Real sesuai flag.
|
||||
- **Aturan precedence eksplisit** (harus didefinisikan sebelum coding, karena ada `role`, `group`, dan `isGroupBased` sekaligus):
|
||||
1. Jika user punya override individual (role-based) → pakai itu.
|
||||
2. Jika tidak ada override individual dan `isGroupBased: true` → pakai izin dari group.
|
||||
3. Jika keduanya tidak ada → deny-by-default.
|
||||
- Tuliskan aturan ini sebagai komentar di `permissionStore.ts`, bukan hanya di dokumen, supaya tidak jadi sumber bug tersembunyi saat logic berkembang.
|
||||
|
||||
### 4. Vue Custom Directive `v-permission`
|
||||
- File baru `plugins/permission.ts` untuk registrasi directive.
|
||||
- Dukungan penggunaan:
|
||||
- Single: `<v-btn v-permission="'canEdit'">Edit</v-btn>`
|
||||
- Multiple (AND): `<v-btn v-permission="['canEdit', 'canDelete']">...</v-btn>`
|
||||
- **Mode hide vs disable**: tambahkan modifier, misal `v-permission:disable="'canDelete'"`, agar tombol bisa di-disable dengan tooltip ("Anda tidak punya izin") alih-alih hilang total tanpa penjelasan — pilih sesuai konteks UX per halaman.
|
||||
|
||||
### 5. `stores/permissionStore.ts` (State Management)
|
||||
- Mengambil data izin dari Local/Real API saat login.
|
||||
- Menyimpan state global.
|
||||
- **Deny-by-default**: jika fetch permission gagal (network error, token expired), state dianggap "tanpa izin sama sekali", bukan default terbuka.
|
||||
- **Refresh strategy**: tentukan apakah perubahan hak akses oleh admin berlaku langsung (polling/refetch berkala) atau baru berlaku setelah re-login. Pilih salah satu secara eksplisit dan dokumentasikan, jangan dibiarkan implisit.
|
||||
|
||||
### 6. Middleware & Dynamic Sidebar
|
||||
- **Client middleware (`middleware/permissions.ts`)**: mencegat rute jika `canAccess` false.
|
||||
- **Server-side guard**: karena project ini SSR (Nuxt), tambahkan pengecekan di server middleware/plugin juga — bukan hanya client — untuk mencegah *flash of unauthorized content* (halaman sempat ter-render sebelum redirect).
|
||||
- **Sidebar (`stores/navItems1.ts`)**: filter menu berdasarkan `canView`, dikunci ke `menuKey`.
|
||||
|
||||
### 7. Keamanan Backend (wajib, non-negotiable)
|
||||
- `v-permission` dan middleware di atas adalah **UX**, bukan kontrol akses sesungguhnya — keduanya berjalan di client dan bisa dilewati siapa saja yang memanggil API langsung.
|
||||
- Backend API asli **wajib** memvalidasi ulang setiap permission di server berdasarkan identitas user dari token, tidak pernah mempercayai payload/izin yang dikirim dari frontend.
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### 1. Verifikasi API Lokal via cURL
|
||||
Pastikan API Lokal mampu membaca dan menyimpan JSON berformat CRUD dengan `menuKey`:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/hak-akses" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"role": "admin",
|
||||
"group": "LOKET",
|
||||
"namaTipeUser": "Admin Loket",
|
||||
"isGroupBased": true,
|
||||
"hakAksesMenu": [
|
||||
{
|
||||
"menuKey": "master-klinik-ruang",
|
||||
"name": "Master Klinik Ruang",
|
||||
"canAccess": true,
|
||||
"canView": true,
|
||||
"canAdd": false,
|
||||
"canEdit": false,
|
||||
"canDelete": false
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. Manual UI Verification
|
||||
1. Buka **Setting > Hak Akses**, atur hak untuk Role tertentu (centang `canView`, matikan `canDelete`).
|
||||
2. Login sebagai akun dengan Role tersebut.
|
||||
3. Buka halaman target.
|
||||
4. **Validasi**: Halaman terbuka, tombol "Delete" tersembunyi/disabled sesuai mode `v-permission`.
|
||||
5. **Validasi fetch gagal**: Simulasikan permission API error (mis. matikan endpoint sementara) → pastikan sistem deny-by-default, bukan default terbuka.
|
||||
6. **Validasi kesiapan API asli**: Cek logika precedence dan toggle flag di `permissionStore.ts`.
|
||||
|
||||
### 3. Automated Testing (baru)
|
||||
- **Unit test** untuk `permissionStore.ts`: precedence role vs group, deny-by-default saat fetch gagal, evaluasi `canAccess`/`canView`/dst.
|
||||
- **Contract test**: bandingkan skema response Mock API vs Real API (setelah Real API tersedia) menggunakan skema TypeScript/zod yang sama, untuk memastikan switch flag benar-benar tanpa perubahan kode lain.
|
||||
- **Directive test**: pastikan `v-permission` menyembunyikan/disable elemen dengan benar untuk kombinasi single dan multiple permission.
|
||||
|
||||
---
|
||||
|
||||
## Strategi Selama Backend Asli Belum Tersedia
|
||||
|
||||
Karena tim belum bisa mengimplementasikan validasi izin di server sungguhan, mock API diperlakukan sebagai **kontrak (contract-first)**, bukan sekadar penyimpanan data sementara. Tujuannya: frontend sudah teruji terhadap semua skenario yang nanti jadi tanggung jawab backend asli, dan tidak perlu dirombak saat migrasi.
|
||||
|
||||
### 1. Mock API mengikuti skema, bukan menerima apa saja
|
||||
- Definisikan interface TypeScript / skema `zod` untuk request dan response `hak-akses` **sekarang**, bukan menunggu backend asli.
|
||||
- Mock API menolak (400) payload yang tidak sesuai skema.
|
||||
- Skema ini menjadi kontrak yang wajib dipatuhi backend asli nanti — perbedaan struktur akan ketahuan lewat contract test, bukan saat production.
|
||||
|
||||
### 2. Simulasikan tanggung jawab yang nanti dipegang backend
|
||||
- Tambahkan endpoint mock `/api/hak-akses/check` yang bisa mensimulasikan penolakan server (403) karena user tidak punya izin — supaya UI dan middleware sudah teruji menangani penolakan dari server, bukan hanya dari state client.
|
||||
- Simulasikan juga kegagalan fetch (delay/error 500) untuk memverifikasi deny-by-default benar-benar berjalan.
|
||||
|
||||
### 3. Tandai eksplisit bagian yang "sementara tidak aman"
|
||||
Beri komentar `TODO(security)` di titik-titik yang wajib diperkuat saat backend asli terpasang, contoh:
|
||||
```ts
|
||||
// TODO(security): saat backend asli terpasang, endpoint ini WAJIB
|
||||
// memvalidasi ulang permission dari token JWT/session di server,
|
||||
// jangan percaya payload role/group yang dikirim dari client.
|
||||
```
|
||||
Ini mencegah asumsi keliru saat handoff bahwa "karena UI sudah mengatur tampilan sesuai izin, backend tidak perlu memvalidasi ulang".
|
||||
|
||||
### 4. Definition of Done — Migrasi ke Backend Asli
|
||||
Checklist ini harus tercentang semua **sebelum** flag `USE_MOCK_PERMISSION_API` dimatikan (`false`) di production:
|
||||
- [ ] Endpoint real API memvalidasi permission berdasarkan identitas dari token (JWT/session), bukan dari body request yang dikirim client.
|
||||
- [ ] Response real API lolos contract test terhadap skema yang sama dengan mock API (field, tipe data, struktur `hakAksesMenu` identik).
|
||||
- [ ] Skenario penolakan server (403) dan fetch gagal (500/timeout) sudah diuji terhadap real API, tidak hanya terhadap mock.
|
||||
- [ ] Ada audit log untuk setiap perubahan hak akses (siapa mengubah, kapan, dari-ke apa) — direkomendasikan mengingat ini fitur kontrol akses.
|
||||
- [ ] Rate limiting pada endpoint pengubahan hak akses, untuk mencegah penyalahgunaan.
|
||||
- [ ] Semua komentar `TODO(security)` di kode sudah diselesaikan atau dipindahkan menjadi tiket tersendiri yang dilacak.
|
||||
@@ -1,70 +1,84 @@
|
||||
// middleware/checkPageAccess.ts
|
||||
// Middleware to check if user has access to the page based on hakAkses
|
||||
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
import { usePermissionStore } from '~/stores/permissionStore';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// On server-side, skip access check - let client handle it
|
||||
// This matches auth.ts behavior and prevents SSR failures when cookie context is missing
|
||||
if (process.server) {
|
||||
console.log('⏭️ Server-side: Skipping page access check (will verify on client)');
|
||||
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();
|
||||
|
||||
const targetPath = to.path.endsWith('/') && to.path.length > 1 ? to.path.slice(0, -1) : to.path;
|
||||
const targetPathLower = targetPath.toLowerCase();
|
||||
const toPathLower = to.path.toLowerCase();
|
||||
|
||||
// Check if user has access to this page
|
||||
// We also check against the raw path just in case, case-insensitive
|
||||
const isAllowed = allowedPages.some(path => {
|
||||
const normalizedAllowed = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
|
||||
const normalizedAllowedLower = normalizedAllowed.toLowerCase();
|
||||
const pathLower = path.toLowerCase();
|
||||
return normalizedAllowedLower === targetPathLower || pathLower === toPathLower;
|
||||
});
|
||||
|
||||
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;
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// Ensure permissions are loaded
|
||||
if (!permissionStore.isLoaded) {
|
||||
const roles = [
|
||||
...(user.value.realm_access?.roles || []),
|
||||
...(user.value.roles || [])
|
||||
];
|
||||
|
||||
const groups: string[] = [];
|
||||
const rawGroups = (user.value as any).groups || [];
|
||||
rawGroups.forEach((g: string) => {
|
||||
const parts = g.split('/').filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
groups.push(parts[1]);
|
||||
} else if (parts.length === 1) {
|
||||
groups.push(parts[0]);
|
||||
}
|
||||
});
|
||||
|
||||
const primaryRole = roles[0] || '';
|
||||
const primaryGroup = groups[0] || '';
|
||||
const username = user.value.preferred_username || user.value.email || user.value.name || '';
|
||||
|
||||
await permissionStore.load(primaryRole, primaryGroup, username);
|
||||
}
|
||||
|
||||
let menuKey = "";
|
||||
if (to.name) {
|
||||
menuKey = to.name.toString().toLowerCase().replace(/_|-/g, '-');
|
||||
}
|
||||
|
||||
// If no explicit route name, allow it for now.
|
||||
if (!menuKey) return;
|
||||
|
||||
// The previous implementation allowed some pages implicitly.
|
||||
// If it's not configured, our can() method returns false.
|
||||
// We should allow access if it's explicitly allowed.
|
||||
let hasAccess = permissionStore.can(menuKey, 'canAccess');
|
||||
|
||||
// Default allow Dashboard for all authenticated users
|
||||
if (menuKey === 'dashboard') {
|
||||
hasAccess = true;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
console.warn(`Access denied to ${to.path}. User lacks 'canAccess' for menuKey: ${menuKey}`);
|
||||
|
||||
// Find a fallback page that the user CAN access
|
||||
const fallbackMenu = permissionStore.permissions.find(p => p.canAccess);
|
||||
if (fallbackMenu && fallbackMenu.menuKey) {
|
||||
// Note: menuKey might not be a valid path, but if we map routeName to menuKey,
|
||||
// we can try to navigate to the route name instead.
|
||||
return navigateTo({ name: fallbackMenu.menuKey });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking page access:', error);
|
||||
// On error, we might want to allow or block. Let's allow but log.
|
||||
return;
|
||||
|
||||
// Final fallback
|
||||
return navigateTo('/');
|
||||
}
|
||||
});
|
||||
@@ -88,6 +88,7 @@ export default defineNuxtConfig({
|
||||
wsBaseUrl: process.env.WS_API_URL || process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws',
|
||||
verificationApiBaseUrl: process.env.ANTRIAN_API_URL || process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1',
|
||||
externalApiBaseUrl: process.env.VISIT_API_URL || (process.env.EXTERNAL_API_BASE_URL ? `${process.env.EXTERNAL_API_BASE_URL}/api/v1` : 'http://10.10.123.135:8084/api/v1'),
|
||||
useMockPermissionApi: process.env.NUXT_PUBLIC_USE_MOCK_PERMISSION_API !== 'false', // Default true until backend is ready
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Generated
+11
-1
@@ -37,7 +37,8 @@
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-carousel": "^0.17.0"
|
||||
"vue3-carousel": "^0.17.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/google-fonts": "^3.2.0",
|
||||
@@ -23601,6 +23602,15 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
+2
-1
@@ -47,7 +47,8 @@
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-carousel": "^0.17.0"
|
||||
"vue3-carousel": "^0.17.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/google-fonts": "^3.2.0",
|
||||
|
||||
+27
-21
@@ -129,6 +129,7 @@
|
||||
prepend-icon="mdi-shield-edit-outline"
|
||||
class="text-capitalize rounded-lg mr-2"
|
||||
@click="editPermissions(item)"
|
||||
v-permission="'canEdit'"
|
||||
>
|
||||
Atur Akses
|
||||
</v-btn>
|
||||
@@ -165,8 +166,8 @@
|
||||
<div class="pa-6 pt-0">
|
||||
<EditHakAkses
|
||||
v-if="editedEntity"
|
||||
:pages="editedEntity.pages"
|
||||
@update:pages="editedEntity.pages = $event"
|
||||
:hakAksesMenu="editedEntity.hakAksesMenu || []"
|
||||
@update:hakAksesMenu="editedEntity.hakAksesMenu = $event"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
@@ -240,7 +241,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
|
||||
import { useNavItemsStore } from '~/stores/navItems1';
|
||||
import { useNavItemsStore, defaultNavItems } from '~/stores/navItems1';
|
||||
import type { HakAkses } from '~/types/setting';
|
||||
|
||||
definePageMeta({
|
||||
@@ -321,6 +322,8 @@ const loadData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Computed list based on active tab
|
||||
const displayItems = computed(() => {
|
||||
let list = [];
|
||||
@@ -328,15 +331,15 @@ const displayItems = computed(() => {
|
||||
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[]) => {
|
||||
// Get all valid menuKeys from current navigation store
|
||||
const allNavKeys = new Set<string>();
|
||||
const extractKeys = (items: any[]) => {
|
||||
items.forEach((item: any) => {
|
||||
if (item.path) allNavPaths.add(item.path);
|
||||
if (item.children) extractPaths(item.children);
|
||||
if (item.menuKey) allNavKeys.add(item.menuKey);
|
||||
if (item.children) extractKeys(item.children);
|
||||
});
|
||||
};
|
||||
extractPaths(navItemsStore.getNavItems);
|
||||
extractKeys(defaultNavItems);
|
||||
|
||||
return list.map((entity: any) => {
|
||||
// Find existing permission mapping
|
||||
@@ -344,18 +347,15 @@ const displayItems = computed(() => {
|
||||
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 };
|
||||
if (!mapping) return { ...entity, hakAksesMenu: [], 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);
|
||||
});
|
||||
// Count only valid pages that exist in navigation and have canAccess
|
||||
const validMenus = (mapping.hakAksesMenu || []).filter((m: any) => m.menuKey && allNavKeys.has(m.menuKey) && m.canAccess);
|
||||
|
||||
return {
|
||||
...entity,
|
||||
pages: mapping.pages, // Keep original for editing
|
||||
validPagesCount: validPages.length,
|
||||
hakAksesMenu: mapping.hakAksesMenu, // Keep original for editing
|
||||
validPagesCount: validMenus.length,
|
||||
status: mapping.status,
|
||||
id_mapping: mapping.id
|
||||
};
|
||||
@@ -401,10 +401,14 @@ const savePermissions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: editedEntity.value.id_mapping, // If null, backend create new
|
||||
namaHakAkses: editedEntity.value.name,
|
||||
id: editedEntity.value.id_mapping || undefined, // Zod optional() expects undefined, not null
|
||||
namaHakAkses: editedEntity.value.type === 'user' ? editedEntity.value.username : editedEntity.value.name,
|
||||
role: editedEntity.value.type === 'role' ? editedEntity.value.name : undefined,
|
||||
group: editedEntity.value.type === 'group' ? editedEntity.value.name : undefined,
|
||||
namaTipeUser: editedEntity.value.type === 'user' ? editedEntity.value.username : undefined,
|
||||
isGroupBased: editedEntity.value.type === 'group',
|
||||
status: 'aktif',
|
||||
pages: editedEntity.value.pages
|
||||
hakAksesMenu: editedEntity.value.hakAksesMenu || []
|
||||
};
|
||||
|
||||
const response = await $fetch<{ success: boolean, message: string }>('/api/hak-akses', {
|
||||
@@ -417,10 +421,12 @@ const savePermissions = async () => {
|
||||
await loadData();
|
||||
await navItemsStore.refreshNavItems();
|
||||
showEditDialog.value = false;
|
||||
} else {
|
||||
showSnackbar(response.message || 'Gagal menyimpan perubahan', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error);
|
||||
showSnackbar('Gagal menyimpan perubahan', 'error');
|
||||
showSnackbar('Terjadi kesalahan pada server saat menyimpan perubahan', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { defineNuxtPlugin } from '#app';
|
||||
import { usePermissionStore } from '~/stores/permissionStore';
|
||||
import type { HakAksesMenu } from '~/server/utils/schemas/permissionSchema';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.directive('permission', {
|
||||
mounted(el, binding) {
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// Determine what action to check based on modifiers or value.
|
||||
// If it's an array: v-permission="['canEdit', 'canDelete']"
|
||||
// If it's a string: v-permission="'canEdit'"
|
||||
|
||||
const permissionsRequired = Array.isArray(binding.value) ? binding.value : [binding.value];
|
||||
|
||||
// Determine the menuKey context.
|
||||
// In a real app, you might inject this context from the page component.
|
||||
// For now, we extract it from the current route name.
|
||||
const currentRoute = useRoute();
|
||||
let menuKey = "";
|
||||
if (currentRoute && currentRoute.name) {
|
||||
// Convert vue-router route name to our menuKey format
|
||||
menuKey = currentRoute.name.toString().toLowerCase().replace(/_|-/g, '-');
|
||||
}
|
||||
|
||||
let hasPermission = true;
|
||||
|
||||
for (const action of permissionsRequired) {
|
||||
if (!permissionStore.can(menuKey, action as keyof HakAksesMenu)) {
|
||||
hasPermission = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
if (binding.arg === 'disable') {
|
||||
el.disabled = true;
|
||||
el.classList.add('v-btn--disabled'); // If it's vuetify button
|
||||
el.setAttribute('title', 'Anda tidak memiliki izin untuk aksi ini');
|
||||
el.style.opacity = '0.5';
|
||||
el.style.pointerEvents = 'none';
|
||||
} else {
|
||||
// Default: hide
|
||||
el.style.display = 'none';
|
||||
|
||||
// Better hide by removing from DOM if possible,
|
||||
// but display: none is safer for custom directives that don't want to break layout engines
|
||||
// To truly remove: el.parentNode?.removeChild(el) (but breaks if Vue tries to update it)
|
||||
}
|
||||
}
|
||||
},
|
||||
updated(el, binding) {
|
||||
// Handle reactivity if permissions change or route changes
|
||||
const permissionStore = usePermissionStore();
|
||||
const permissionsRequired = Array.isArray(binding.value) ? binding.value : [binding.value];
|
||||
const currentRoute = useRoute();
|
||||
let menuKey = "";
|
||||
if (currentRoute && currentRoute.name) {
|
||||
menuKey = currentRoute.name.toString().toLowerCase().replace(/_|-/g, '-');
|
||||
}
|
||||
|
||||
let hasPermission = true;
|
||||
|
||||
for (const action of permissionsRequired) {
|
||||
if (!permissionStore.can(menuKey, action as keyof HakAksesMenu)) {
|
||||
hasPermission = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
if (binding.arg === 'disable') {
|
||||
el.disabled = true;
|
||||
el.classList.add('v-btn--disabled');
|
||||
el.setAttribute('title', 'Anda tidak memiliki izin untuk aksi ini');
|
||||
el.style.opacity = '0.5';
|
||||
el.style.pointerEvents = 'none';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// Restore if permission was granted
|
||||
if (binding.arg === 'disable') {
|
||||
el.disabled = false;
|
||||
el.classList.remove('v-btn--disabled');
|
||||
el.removeAttribute('title');
|
||||
el.style.opacity = '1';
|
||||
el.style.pointerEvents = 'auto';
|
||||
} else {
|
||||
el.style.display = ''; // Revert to original display
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// server/api/hak-akses/check.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
// This is a mock endpoint to simulate backend checking permission.
|
||||
// In production, the backend will validate the user's token directly.
|
||||
const method = event.method;
|
||||
if (method === 'POST') {
|
||||
const body = await readBody(event);
|
||||
const { menuKey, action } = body;
|
||||
|
||||
// Simulating some random failure or checking logic
|
||||
if (!menuKey || !action) {
|
||||
return createError({ statusCode: 400, statusMessage: 'Bad Request: menuKey and action required' });
|
||||
}
|
||||
|
||||
// Just simulating success for now.
|
||||
return {
|
||||
success: true,
|
||||
message: `Mock check: User has ${action} permission for ${menuKey}`,
|
||||
allowed: true
|
||||
}
|
||||
}
|
||||
return createError({ statusCode: 405, statusMessage: 'Method Not Allowed' });
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { HakAkses } from '~/types/setting';
|
||||
import { randomUUID } from 'node:crypto'; // Use standard node crypto
|
||||
import { hakAksesPayloadSchema, type HakAksesPayload } from '~/server/utils/schemas/permissionSchema';
|
||||
|
||||
const filePath = path.resolve('data/mock/hakAkses.json');
|
||||
|
||||
// Helper to read JSON file
|
||||
const readData = (): HakAkses[] => {
|
||||
const readData = (): HakAksesPayload[] => {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Ensure directory exists
|
||||
@@ -26,7 +26,7 @@ const readData = (): HakAkses[] => {
|
||||
};
|
||||
|
||||
// Helper to write JSON file
|
||||
const writeData = (data: HakAkses[]): boolean => {
|
||||
const writeData = (data: HakAksesPayload[]): boolean => {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 4), 'utf-8');
|
||||
return true;
|
||||
@@ -51,7 +51,20 @@ export default defineEventHandler(async (event) => {
|
||||
// POST - Create or Update hak akses
|
||||
if (method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const rawBody = await readBody(event);
|
||||
|
||||
// Validate payload with Zod Schema (Contract testing)
|
||||
const validationResult = hakAksesPayloadSchema.safeParse(rawBody);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Payload invalid: Gagal menyimpan hak akses',
|
||||
error: validationResult.error.format()
|
||||
};
|
||||
}
|
||||
|
||||
const body = validationResult.data;
|
||||
const data = readData();
|
||||
|
||||
if (body.id) {
|
||||
@@ -63,18 +76,21 @@ export default defineEventHandler(async (event) => {
|
||||
...body
|
||||
};
|
||||
} else {
|
||||
data.push(body);
|
||||
data.push(body as HakAksesPayload);
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const newId = randomUUID();
|
||||
const newHakAkses: HakAkses = {
|
||||
id: newId,
|
||||
namaHakAkses: body.namaHakAkses,
|
||||
status: body.status || 'aktif',
|
||||
pages: body.pages || []
|
||||
};
|
||||
data.push(newHakAkses);
|
||||
body.id = randomUUID();
|
||||
|
||||
// For backward compatibility with HakAkses.vue UI that expects 'namaHakAkses'
|
||||
if (!body.namaHakAkses) {
|
||||
body.namaHakAkses = body.role || body.group || body.namaTipeUser || "Unknown";
|
||||
}
|
||||
if (!body.status) {
|
||||
body.status = 'aktif';
|
||||
}
|
||||
|
||||
data.push(body as HakAksesPayload);
|
||||
}
|
||||
|
||||
const success = writeData(data);
|
||||
@@ -91,7 +107,7 @@ export default defineEventHandler(async (event) => {
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Gagal menyimpan hak akses',
|
||||
message: 'Gagal memproses request',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const hakAksesMenuSchema = z.object({
|
||||
menuKey: z.string().min(1, "menuKey is required"),
|
||||
name: z.string().min(1, "name is required"),
|
||||
canAccess: z.boolean().default(false),
|
||||
canView: z.boolean().default(false),
|
||||
canAdd: z.boolean().default(false),
|
||||
canEdit: z.boolean().default(false),
|
||||
canDelete: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const hakAksesPayloadSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
namaTipeUser: z.string().optional(),
|
||||
isGroupBased: z.boolean().default(true),
|
||||
status: z.string().optional(), // Tambahan dari mock lama
|
||||
namaHakAkses: z.string().optional(), // Tambahan dari mock lama
|
||||
hakAksesMenu: z.array(hakAksesMenuSchema).default([]),
|
||||
});
|
||||
|
||||
export type HakAksesMenu = z.infer<typeof hakAksesMenuSchema>;
|
||||
export type HakAksesPayload = z.infer<typeof hakAksesPayloadSchema>;
|
||||
+37
-30
@@ -2,33 +2,35 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { computed, ref } from 'vue'; // Import computed dari Vue
|
||||
import { useHakAkses } from '~/composables/useHakAkses';
|
||||
import { usePermissionStore } from '~/stores/permissionStore';
|
||||
|
||||
interface NavItem {
|
||||
id: number;
|
||||
name: string; // Menggantikan 'title'
|
||||
path: string; // Menggantikan 'to'
|
||||
icon: string;
|
||||
menuKey?: string; // New field for permission checking
|
||||
children?: NavItem[];
|
||||
badge?: string; // Tambahkan properti badge
|
||||
}
|
||||
|
||||
// Initial default navigation items
|
||||
const defaultNavItems: NavItem[] = [
|
||||
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard" },
|
||||
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun" },
|
||||
export const defaultNavItems: NavItem[] = [
|
||||
{ id: 1, name: "Dashboard", icon: "mdi-view-dashboard", path: "/dashboard", menuKey: "dashboard" },
|
||||
{ id: 2, name: "Verifikasi Akun", icon: "mdi-account-check-outline", path:"/verifikasiAkun/VerifikasiAkun", menuKey: "verifikasiakun-verifikasiakun" },
|
||||
{
|
||||
id: 3,
|
||||
name: "Check In",
|
||||
icon: "mdi-file-document-edit-outline",
|
||||
path: "/CheckInPasien/checkIn"
|
||||
path: "/CheckInPasien/checkIn",
|
||||
menuKey: "checkinpasien-checkin"
|
||||
// badge: "3",
|
||||
},
|
||||
{ id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket" },
|
||||
{ id: 6, name: "Admin Klinik Ruang", icon: "mdi-door-open", path: "/AdminKlinikRuang" },
|
||||
{ id: 4, name: "Admin Loket", icon: "mdi-account-supervisor-outline", path: "/AdminLoket", menuKey: "adminloket" },
|
||||
{ id: 6, name: "Admin Klinik Ruang", icon: "mdi-door-open", path: "/AdminKlinikRuang", menuKey: "adminklinikruang" },
|
||||
// { id: 7, name: "Admin Penunjang", icon: "mdi-plus-box-outline", path: "/AdminPenunjang" },
|
||||
// { id: 8, name: "Buat Antrean", icon: "mdi-account-multiple-plus-outline", path: "/BuatAntrean" },
|
||||
{ id: 9, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien" },
|
||||
{ id: 9, name: "Monitoring Pasien", icon: "mdi-account-group-outline", path: "/MonitoringPasien/monitoringPasien", menuKey: "monitoringpasien-monitoringpasien" },
|
||||
{
|
||||
id: 10,
|
||||
name: "Layar Informasi",
|
||||
@@ -36,13 +38,12 @@ const defaultNavItems: NavItem[] = [
|
||||
path: "",
|
||||
children: [
|
||||
// { id: 10, name: "Anjungan", path: "/Anjungan/Anjungan", icon: "mdi-circle-small" },
|
||||
{ id: 11, name: "Anjungan", path: "/anjungan/anjungancopy", icon: "mdi-circle-small" },
|
||||
{ id: 11, name: "Anjungan", path: "/anjungan/anjungancopy", icon: "mdi-circle-small", menuKey: "anjungan-anjungancopy" },
|
||||
// { id: 11, name: "Klinik", path: "/Anjungan/AntrianKlinik", icon: "mdi-circle-small" },
|
||||
{ id: 12, name: "Klinik Ruang", path: "/Anjungan/AntrianKlinikRuang", icon: "mdi-circle-small"},
|
||||
{ id: 12, name: "Klinik Ruang", path: "/Anjungan/AntrianKlinikRuang", icon: "mdi-circle-small", menuKey: "anjungan-antrianklinikruang"},
|
||||
// { id: 13, name: "Penunjang", path: "/Anjungan/AntrianPenunjang", icon: "mdi-circle-small"},
|
||||
{id: 14, name: "Loket", path: "/Anjungan/AntrianLoket", icon: "mdi-circle-small"},
|
||||
{id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small"},
|
||||
|
||||
{id: 14, name: "Loket", path: "/Anjungan/AntrianLoket", icon: "mdi-circle-small", menuKey: "anjungan-antrianloket"},
|
||||
{id: 15, name: "Antrean Masuk", path: "/Anjungan/AntreanMasuk", icon: "mdi-circle-small", menuKey: "anjungan-antreanmasuk"},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -51,20 +52,20 @@ const defaultNavItems: NavItem[] = [
|
||||
icon: "mdi-cog-outline",
|
||||
path: "",
|
||||
children: [
|
||||
{ id: 16, name: "Hak Akses", path: "/Setting/HakAkses", icon: "mdi-circle-small" },
|
||||
{ id: 17, name: "User Login", path: "/Setting/UserLogin", icon: "mdi-circle-small" },
|
||||
{ id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small" },
|
||||
{ id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small" },
|
||||
{ id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small" },
|
||||
{ id: 21, name: "Master Klinik Ruang", path: "/Setting/MasterKlinikRuang", icon: "mdi-circle-small" },
|
||||
{ id: 16, name: "Hak Akses", path: "/Setting/HakAkses", icon: "mdi-circle-small", menuKey: "setting-hakakses" },
|
||||
{ id: 17, name: "User Login", path: "/Setting/UserLogin", icon: "mdi-circle-small", menuKey: "setting-userlogin" },
|
||||
{ id: 18, name: "Master Anjungan", path: "/Setting/MasterAnjungan", icon: "mdi-circle-small", menuKey: "setting-masteranjungan" },
|
||||
{ id: 19, name: "Master Loket", path: "/Setting/MasterLoket", icon: "mdi-circle-small", menuKey: "setting-masterloket" },
|
||||
{ id: 20, name: "Master Klinik", path: "/Setting/MasterKlinik", icon: "mdi-circle-small", menuKey: "setting-masterklinik" },
|
||||
{ id: 21, name: "Master Klinik Ruang", path: "/Setting/MasterKlinikRuang", icon: "mdi-circle-small", menuKey: "setting-masterklinikruang" },
|
||||
// { id: 22, name: "Master Penunjang", path: "/Setting/MasterPenunjang", icon: "mdi-circle-small" },
|
||||
// { id: 23, name: "Screen", path: "/Setting/Screen", icon: "mdi-circle-small" },
|
||||
{ id: 24, name: "Screen Antrean Masuk", path: "/Setting/ScreenAntreanMasuk", icon: "mdi-circle-small" },
|
||||
{ id: 24, name: "Screen Antrean Masuk", path: "/Setting/ScreenAntreanMasuk", icon: "mdi-circle-small", menuKey: "setting-screenantreanmasuk" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_VERSION = '1.3'; // Increment this if you change structure or default paths
|
||||
const STORAGE_VERSION = '1.5'; // Increment this if you change structure or default paths
|
||||
|
||||
export const useNavItemsStore = defineStore('navItems', () => {
|
||||
const storedVersion = useLocalStorage('navItems_version', '0');
|
||||
@@ -104,14 +105,13 @@ export const useNavItemsStore = defineStore('navItems', () => {
|
||||
const filteredNavItems = ref<NavItem[]>(defaultNavItems);
|
||||
|
||||
async function refreshNavItems() {
|
||||
const { getAllowedPages } = useHakAkses();
|
||||
const allowedPages = await getAllowedPages();
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
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;
|
||||
// Wait for permissions to be loaded if they aren't
|
||||
if (!permissionStore.isLoaded) {
|
||||
// We assume middleware already triggered load, if not, wait
|
||||
// Typically, refreshNavItems is called after checkAuth, which doesn't auto-load now
|
||||
// But checkPageAccess does. Let's just use what's in store.
|
||||
}
|
||||
|
||||
const filterItems = (items: NavItem[]): NavItem[] => {
|
||||
@@ -126,8 +126,15 @@ export const useNavItemsStore = defineStore('navItems', () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a child or leaf, check if path is in allowedPages
|
||||
return allowedPages.includes(item.path);
|
||||
// If it's a child or leaf, check permissionStore
|
||||
if (item.menuKey) {
|
||||
if (item.menuKey === 'dashboard') return true;
|
||||
return permissionStore.can(item.menuKey, 'canAccess');
|
||||
}
|
||||
|
||||
// If no menuKey configured, fallback to previous allow/deny logic or true
|
||||
// Safe bet is to hide if not mapped, but we mapped everything in defaultNavItems.
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+112
-20
@@ -1,24 +1,116 @@
|
||||
// /stores/permissionStore.ts
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import type { HakAksesPayload, HakAksesMenu } from "~/server/utils/schemas/permissionSchema";
|
||||
|
||||
export const usePermissionStore = defineStore("permission", {
|
||||
state: () => ({
|
||||
data: null as any,
|
||||
}),
|
||||
actions: {
|
||||
async load(path: string) {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
const group = parts[1] || "";
|
||||
const role = parts.at(-1)?.toLowerCase() || "";
|
||||
export const usePermissionStore = defineStore("permission", () => {
|
||||
const permissions = ref<HakAksesMenu[]>([]);
|
||||
const isLoaded = ref(false);
|
||||
const isError = ref(false);
|
||||
|
||||
/**
|
||||
* Load permissions for the current user's role and group
|
||||
*/
|
||||
const load = async (role: string, group: string = "", username: string = "") => {
|
||||
try {
|
||||
const config = useRuntimeConfig();
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const origin = new URL(apiBase).origin;
|
||||
const url = `${origin}/api/permission?roles=${role}&groups=${group}`;
|
||||
const { data } = await useFetch(url);
|
||||
this.data = data.value;
|
||||
},
|
||||
can(action: string) {
|
||||
return this.data?.[action] === true;
|
||||
},
|
||||
},
|
||||
const useMock = config.public.useMockPermissionApi;
|
||||
|
||||
let url = "";
|
||||
if (useMock) {
|
||||
url = `/api/hak-akses`; // Mock endpoint (returns all config)
|
||||
} else {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
// Ensure valid origin parsing
|
||||
const origin = apiBase.startsWith('http') ? new URL(apiBase).origin : 'http://10.10.123.140:8089';
|
||||
// In a real API, passing username might be needed if they support user-level overrides
|
||||
url = `${origin}/api/v1/permission?roles=${role}&groups=${group}&username=${username}`;
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<any>(url);
|
||||
|
||||
if (error.value) {
|
||||
throw new Error(error.value.message || "Failed to fetch permissions");
|
||||
}
|
||||
|
||||
isError.value = false;
|
||||
|
||||
// Handle Mock vs Real API response differences
|
||||
if (useMock && data.value?.success) {
|
||||
// Mock returns all permission configs. Find the matching one based on precedence:
|
||||
const allConfigs = data.value.data as HakAksesPayload[];
|
||||
|
||||
// Precedence 0: Exact User Match (Specific Override)
|
||||
let matchedConfig = allConfigs.find(c => c.namaTipeUser?.toLowerCase() === username.toLowerCase() || c.namaHakAkses?.toLowerCase() === username.toLowerCase());
|
||||
|
||||
// Precedence 1: Exact Role Match
|
||||
if (!matchedConfig && role) {
|
||||
matchedConfig = allConfigs.find(c => c.role?.toLowerCase() === role.toLowerCase() && !c.isGroupBased);
|
||||
}
|
||||
|
||||
// Precedence 2: Group Match
|
||||
if (!matchedConfig && group) {
|
||||
matchedConfig = allConfigs.find(c => c.isGroupBased && c.group?.toLowerCase() === group.toLowerCase());
|
||||
}
|
||||
|
||||
// If found, use it, else empty (Deny-by-default)
|
||||
permissions.value = matchedConfig?.hakAksesMenu || [];
|
||||
} else if (!useMock && data.value?.data) {
|
||||
// Real API returns { message: "...", data: [ { id, create, read, update, delete, pagename } ] }
|
||||
// We need to map Real API schema back to HakAksesMenu schema (contract mapping)
|
||||
const realApiData = Array.isArray(data.value.data) ? data.value.data : [];
|
||||
permissions.value = realApiData.map((item: any) => ({
|
||||
menuKey: item.pagename?.toLowerCase().replace(/\s+/g, '-') || "", // Best effort mapping if API doesn't send menuKey
|
||||
name: item.pagename || "Unknown",
|
||||
canAccess: item.read === true,
|
||||
canView: item.read === true,
|
||||
canAdd: item.create === true,
|
||||
canEdit: item.update === true,
|
||||
canDelete: item.delete === true
|
||||
}));
|
||||
} else {
|
||||
// Unrecognized format -> Deny-by-default
|
||||
permissions.value = [];
|
||||
}
|
||||
|
||||
isLoaded.value = true;
|
||||
} catch (err) {
|
||||
console.error("[permissionStore] Failed to load permissions:", err);
|
||||
// Deny-by-default on fetch error
|
||||
permissions.value = [];
|
||||
isError.value = true;
|
||||
isLoaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a specific menuKey has a specific permission action.
|
||||
* If the fetch failed or config is missing, it returns false (Deny-by-default).
|
||||
*/
|
||||
const can = (menuKey: string, action: keyof HakAksesMenu = 'canAccess'): boolean => {
|
||||
// If we haven't loaded yet or there was an error, deny everything
|
||||
if (!isLoaded.value || isError.value) return false;
|
||||
|
||||
// // TODO(security): This check is purely for UX.
|
||||
// // True access control must be validated on the backend!
|
||||
|
||||
// Find the menu configuration
|
||||
const menuConfig = permissions.value.find(
|
||||
(m) => m.menuKey.toLowerCase() === menuKey.toLowerCase()
|
||||
);
|
||||
|
||||
if (!menuConfig) return false; // Not configured -> deny
|
||||
|
||||
return menuConfig[action] === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the store (e.g. on logout)
|
||||
*/
|
||||
const clear = () => {
|
||||
permissions.value = [];
|
||||
isLoaded.value = false;
|
||||
isError.value = false;
|
||||
};
|
||||
|
||||
return { permissions, isLoaded, isError, load, can, clear };
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
const fs = require('fs');
|
||||
const navContent = fs.readFileSync('stores/navItems1.ts', 'utf-8');
|
||||
const keys = [...navContent.matchAll(/menuKey:\s*['"]([^'"]+)['"]/g)].map(m => m[1]);
|
||||
|
||||
console.log('Extracted keys:', keys);
|
||||
|
||||
const data = JSON.parse(fs.readFileSync('data/mock/hakAkses.json', 'utf-8'));
|
||||
|
||||
const menus = keys.map(k => ({
|
||||
menuKey: k,
|
||||
name: k,
|
||||
canAccess: true,
|
||||
canView: true,
|
||||
canAdd: true,
|
||||
canEdit: true,
|
||||
canDelete: true
|
||||
}));
|
||||
|
||||
const usernameOverrides = ['[email protected]', 'akbar44', 'Akbar Attallah'];
|
||||
|
||||
for (const user of usernameOverrides) {
|
||||
const existing = data.find(d => d.namaTipeUser === user || d.namaHakAkses === user);
|
||||
if (existing) {
|
||||
existing.hakAksesMenu = menus;
|
||||
existing.namaTipeUser = user;
|
||||
} else {
|
||||
data.push({
|
||||
id: require('crypto').randomUUID(),
|
||||
namaHakAkses: user,
|
||||
namaTipeUser: user,
|
||||
status: 'aktif',
|
||||
isGroupBased: false,
|
||||
hakAksesMenu: menus
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync('data/mock/hakAkses.json', JSON.stringify(data, null, 4));
|
||||
console.log('Granted full access to ' + usernameOverrides.join(', '));
|
||||
Reference in New Issue
Block a user