diff --git a/components/HakAkses/EditHakAkses.vue b/components/HakAkses/EditHakAkses.vue
index 4c1a3d8..72fa349 100644
--- a/components/HakAkses/EditHakAkses.vue
+++ b/components/HakAkses/EditHakAkses.vue
@@ -4,29 +4,76 @@
| Halaman |
- Akses |
+ Access |
+ View |
+ Add |
+ Edit |
+ Delete |
-
-
+
+
|
- {{ item.name }}
+ {{ item.name }}
|
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
-
|
@@ -37,24 +84,24 @@
diff --git a/docs/hakakses_implementation_plan_final.md b/docs/hakakses_implementation_plan_final.md
new file mode 100644
index 0000000..4410637
--- /dev/null
+++ b/docs/hakakses_implementation_plan_final.md
@@ -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 `` / `` 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: `Edit`
+ - Multiple (AND): `...`
+- **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.
diff --git a/middleware/checkPageAccess.ts b/middleware/checkPageAccess.ts
index a732d07..d3da354 100644
--- a/middleware/checkPageAccess.ts
+++ b/middleware/checkPageAccess.ts
@@ -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('/');
}
});
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 63caa25..d0587c2 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -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
},
},
diff --git a/package-lock.json b/package-lock.json
index 144b5eb..3b4a586 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index c091f3a..b66d274 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pages/Setting/HakAkses.vue b/pages/Setting/HakAkses.vue
index a0a8146..428d8b7 100644
--- a/pages/Setting/HakAkses.vue
+++ b/pages/Setting/HakAkses.vue
@@ -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
@@ -165,8 +166,8 @@
@@ -240,7 +241,7 @@